diff --git a/app/build.gradle b/app/build.gradle index da9b8eb..7df8a63 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 26f6c08..a72e4e7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,7 +17,7 @@ tools:ignore="UnusedAttribute" android:theme="@style/Theme.Mealient"> diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt index b0589b2..4e2b660 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt @@ -6,11 +6,13 @@ interface AuthRepo { val isAuthorizedFlow: Flow - 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() } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt index 5955e7f..8486e6a 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt @@ -6,9 +6,15 @@ interface AuthStorage { val authHeaderFlow: Flow - 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? } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt index 6e26772..6b8dbb8 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt @@ -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 - 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 { diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt index 94243d4..6570e95 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt @@ -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 - get() = preferencesStorage.authHeaderKey override val authHeaderFlow: Flow - 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" } -} +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionInfo.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionInfo.kt index d706d60..0e24c3b 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionInfo.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionInfo.kt @@ -4,4 +4,4 @@ data class VersionInfo( val production: Boolean, val version: String, val demoStatus: Boolean, -) +) \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/BaseURLStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/BaseURLStorageImpl.kt similarity index 88% rename from app/src/main/java/gq/kirmanak/mealient/data/baseurl/BaseURLStorageImpl.kt rename to app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/BaseURLStorageImpl.kt index 2081bac..64280cf 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/BaseURLStorageImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/BaseURLStorageImpl.kt @@ -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 diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionDataSourceImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/VersionDataSourceImpl.kt similarity index 85% rename from app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionDataSourceImpl.kt rename to app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/VersionDataSourceImpl.kt index d779a03..4a472f9 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionDataSourceImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/VersionDataSourceImpl.kt @@ -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 diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionResponse.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/VersionResponse.kt similarity index 86% rename from app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionResponse.kt rename to app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/VersionResponse.kt index 9529415..3c7efe7 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionResponse.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/VersionResponse.kt @@ -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, -) +) \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionService.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/VersionService.kt similarity index 73% rename from app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionService.kt rename to app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/VersionService.kt index 0271550..4f34f2f 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionService.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/VersionService.kt @@ -1,4 +1,4 @@ -package gq.kirmanak.mealient.data.baseurl +package gq.kirmanak.mealient.data.baseurl.impl import retrofit2.http.GET diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt new file mode 100644 index 0000000..c2466df --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/OkHttpBuilder.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/OkHttpBuilder.kt deleted file mode 100644 index feae5e9..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/OkHttpBuilder.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitBuilder.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitBuilder.kt index 2f6cb3e..539405e 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitBuilder.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitBuilder.kt @@ -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 ) { diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSourceImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSourceImpl.kt index e6b0f0a..4c2c2c2 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSourceImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSourceImpl.kt @@ -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, ) : RecipeDataSource { override suspend fun requestRecipes(start: Int, limit: Int): List { 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() } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeService.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeService.kt index 19a64b9..d21516a 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeService.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeService.kt @@ -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 @GET("/api/recipes/{recipe_slug}") suspend fun getRecipe( @Path("recipe_slug") recipeSlug: String, - @Header("Authorization") authHeader: String?, ): GetRecipeResponse } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/storage/PreferencesStorage.kt b/app/src/main/java/gq/kirmanak/mealient/data/storage/PreferencesStorage.kt index f63dc9b..31e9174 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/storage/PreferencesStorage.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/storage/PreferencesStorage.kt @@ -7,8 +7,6 @@ interface PreferencesStorage { val baseUrlKey: Preferences.Key - val authHeaderKey: Preferences.Key - val isDisclaimerAcceptedKey: Preferences.Key suspend fun getValue(key: Preferences.Key): T? diff --git a/app/src/main/java/gq/kirmanak/mealient/data/storage/PreferencesStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/storage/PreferencesStorageImpl.kt index a382682..de71ff0 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/storage/PreferencesStorageImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/storage/PreferencesStorageImpl.kt @@ -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 getValue(key: Preferences.Key): T? { diff --git a/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt index 18e247a..cb87c5f 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt @@ -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 = retrofitBuilder.createServiceFactory(baseURLStorage) + ): ServiceFactory { + 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 } diff --git a/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt index 0e5820a..f205096 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt @@ -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 = retrofitBuilder.createServiceFactory(baseURLStorage) + ): ServiceFactory { + return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage) + } } @Binds diff --git a/app/src/main/java/gq/kirmanak/mealient/di/NetworkModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/NetworkModule.kt index 80de298..0a49c38 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/NetworkModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/NetworkModule.kt @@ -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 diff --git a/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt index 790d812..8b3ad9e 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt @@ -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 = retrofitBuilder.createServiceFactory(baseURLStorage) + ): ServiceFactory { + return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage) + } @Provides @Singleton diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt index 17186e9..b597b95 100644 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt @@ -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 = callbackFlow { - val callback = addCallback { trySend(Unit) } - awaitClose { - callback.isEnabled = false - callback.remove() - } -} - inline fun Fragment.collectWithViewLifecycle( flow: Flow, crossinline collector: suspend (T) -> Unit, -) = viewLifecycleOwner.lifecycleScope.launch { flow.collect(collector) } +) = viewLifecycleOwner.lifecycleScope.launch { flow.collect(collector) } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/RemoteToLocalMappings.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/RemoteToLocalMappings.kt index 394ff01..7686ddc 100644 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/RemoteToLocalMappings.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/RemoteToLocalMappings.kt @@ -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 diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt index 37b65c9..56d2b72 100644 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt @@ -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 SharedPreferences.prefsChangeFlow( + valueReader: SharedPreferences.() -> T, +): Flow = callbackFlow { + fun sendValue() = trySend(valueReader()).logErrors("prefsChangeFlow") + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> sendValue() } + sendValue() + registerOnSharedPreferenceChangeListener(listener) + awaitClose { unregisterOnSharedPreferenceChangeListener(listener) } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt similarity index 82% rename from app/src/main/java/gq/kirmanak/mealient/MainActivity.kt rename to app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt index 1446e96..478002d 100644 --- a/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt @@ -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() + private val viewModel by viewModels() 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()) + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivityViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivityViewModel.kt new file mode 100644 index 0000000..6815084 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivityViewModel.kt @@ -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 + get() = authenticationStateFlow.asLiveData() + + fun logout() { + Timber.v("logout() called") + viewModelScope.launch { authRepo.logout() } + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt index 820ae22..1aa024c 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt @@ -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() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState") - executeOnceOnBackPressed { viewModel.authRequested = false } - } + private val viewModel by viewModels() 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) { diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationState.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationState.kt index 70ec901..72429c7 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationState.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationState.kt @@ -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") diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt index b411758..3b975f5 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt @@ -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 - get() = authenticationStateFlow.asLiveData() - var authRequested: Boolean by authRequestsFlow::value - var showLoginButton: Boolean by showLoginButtonFlow::value + private val _authenticationResult = MutableLiveData>() + val authenticationResult: LiveData> + 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") - } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt index 6c8abce..d30441e 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt @@ -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) { + 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 -> { diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenState.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenState.kt deleted file mode 100644 index bc6bf65..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenState.kt +++ /dev/null @@ -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, -) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt index 33b932e..38a8257 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt @@ -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 - get() = _screenState + private val _checkURLResult = MutableLiveData>() + val checkURLResult: LiveData> 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 { diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerViewModel.kt index 0c796d7..7e8e000 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerViewModel.kt @@ -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 get() = disclaimerStorage.isDisclaimerAcceptedFlow.asLiveData() - private val _okayCountDown = MutableLiveData(FULL_COUNT_DOWN_SEC) val okayCountDown: LiveData = _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) } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/picasso/PicassoBuilder.kt b/app/src/main/java/gq/kirmanak/mealient/ui/picasso/PicassoBuilder.kt index 31e885f..a2cea53 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/picasso/PicassoBuilder.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/picasso/PicassoBuilder.kt @@ -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 { diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt index dab5abd..14ba126 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt @@ -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() - private val authViewModel by activityViewModels() - - 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() 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 } } \ No newline at end of file diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml index 3986213..041006d 100644 --- a/app/src/main/res/layout/main_activity.xml +++ b/app/src/main/res/layout/main_activity.xml @@ -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"> + tools:layout="@layout/fragment_authentication"> + + - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 532e868..c60f100 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,18 +12,20 @@ Ingredients Instructions 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. - Okay - Step: %d - E-mail can\'t be empty - Password can\'t be empty URL can\'t be empty - E-mail or password is incorrect. Can\'t connect, check address. Unexpected response. Is it Mealie? - Something went wrong, please try again. Check URL format: %s Proceed @string/fragment_authentication_unknown_error @string/menu_main_toolbar_login Login + Okay + Step: %d + E-mail can\'t be empty + Password can\'t be empty + E-mail or password is incorrect. + Something went wrong, please try again. + Mealient + mealientAuthToken \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImplTest.kt index 5f414d4..746c11e 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImplTest.kt @@ -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() } diff --git a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt index 466a26c..562dd64 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt @@ -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) } + } +} \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImplTest.kt index 77e2ea6..b19f22a 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImplTest.kt @@ -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) } } \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/data/baseurl/BaseURLStorageImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/baseurl/BaseURLStorageImplTest.kt index 8d5aa5f..256139a 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/baseurl/BaseURLStorageImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/baseurl/BaseURLStorageImplTest.kt @@ -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 diff --git a/app/src/test/java/gq/kirmanak/mealient/data/baseurl/VersionDataSourceImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/baseurl/VersionDataSourceImplTest.kt index eed1861..7320340 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/baseurl/VersionDataSourceImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/baseurl/VersionDataSourceImplTest.kt @@ -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 diff --git a/app/src/test/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptorTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptorTest.kt new file mode 100644 index 0000000..e8419dd --- /dev/null +++ b/app/src/test/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptorTest.kt @@ -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() + + 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() + + 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() +} \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactoryTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactoryTest.kt index 2fc42c0..05a7c93 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactoryTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactoryTest.kt @@ -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 diff --git a/app/src/test/java/gq/kirmanak/mealient/data/storage/PreferencesStorageImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/storage/PreferencesStorageImplTest.kt index b250045..e9ecca5 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/storage/PreferencesStorageImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/storage/PreferencesStorageImplTest.kt @@ -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() } } \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModelTest.kt b/app/src/test/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModelTest.kt index 2379b53..d633c9f 100644 --- a/app/src/test/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModelTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModelTest.kt @@ -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 {