From 096b5389bd92400c289915f697d5a2a49b88ea78 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Sun, 3 Apr 2022 16:11:24 +0500 Subject: [PATCH 01/16] Use AccountManager --- app/src/main/AndroidManifest.xml | 31 ++-- .../data/auth/impl/AuthStorageImpl.kt | 19 ++- .../gq/kirmanak/mealient/di/AuthModule.kt | 20 ++- .../service/auth/AccountAuthenticatorImpl.kt | 141 ++++++++++++++++++ .../service/auth/AccountManagerInteractor.kt | 15 ++ .../auth/AccountManagerInteractorImpl.kt | 59 ++++++++ .../service/auth/AccountParameters.kt | 6 + .../mealient/service/auth/AuthExtensions.kt | 56 +++++++ .../service/auth/AuthenticationService.kt | 16 ++ .../service/auth/AuthenticatorException.kt | 52 +++++++ .../mealient/ui/auth/AuthenticatorActivity.kt | 9 ++ .../res/layout/authenticator_activity.xml | 38 +++++ .../res/navigation/authenticator_graph.xml | 13 ++ app/src/main/res/values/strings.xml | 14 +- .../main/res/xml/account_authenticator.xml | 6 + 15 files changed, 475 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/service/auth/AccountParameters.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticationService.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticatorActivity.kt create mode 100644 app/src/main/res/layout/authenticator_activity.xml create mode 100644 app/src/main/res/navigation/authenticator_graph.xml create mode 100644 app/src/main/res/xml/account_authenticator.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 26f6c08..49dab32 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,16 +15,27 @@ android:roundIcon="@mipmap/ic_launcher" android:supportsRtl="true" tools:ignore="UnusedAttribute" - android:theme="@style/Theme.Mealient"> - - - + android:theme="@style/Theme.Mealient"> + + + - - - - + + + + + + + + + + + \ No newline at end of file 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..a15ac30 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,15 +1,18 @@ package gq.kirmanak.mealient.data.auth.impl import androidx.datastore.preferences.core.Preferences +import dagger.hilt.android.scopes.ActivityScoped import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.storage.PreferencesStorage +import gq.kirmanak.mealient.service.auth.AccountManagerInteractor import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import timber.log.Timber import javax.inject.Inject -import javax.inject.Singleton -@Singleton +@ActivityScoped class AuthStorageImpl @Inject constructor( + private val accountManagerInteractorImpl: AccountManagerInteractor, private val preferencesStorage: PreferencesStorage, ) : AuthStorage { @@ -30,6 +33,18 @@ class AuthStorageImpl @Inject constructor( return token } + override fun authHeaderObservable(): Flow { + Timber.v("authHeaderObservable() called") + return accountManagerInteractorImpl.accountUpdatesFlow() + .map { it.firstOrNull() } + .map { account -> + account ?: return@map null + runCatching { accountManagerInteractorImpl.getAuthToken(account) } + .onFailure { Timber.e(it, "authHeaderObservable: can't get token") } + .getOrNull() + } + } + override suspend fun clearAuthData() { Timber.v("clearAuthData() called") preferencesStorage.removeValues(authHeaderKey) 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..66f556d 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt @@ -5,6 +5,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import gq.kirmanak.mealient.R import gq.kirmanak.mealient.data.auth.AuthDataSource import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthStorage @@ -16,6 +17,7 @@ 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 gq.kirmanak.mealient.service.auth.AccountParameters import javax.inject.Singleton @Module @@ -24,12 +26,26 @@ interface AuthModule { companion object { + const val ACCOUNT_TYPE = "Mealient" + @Provides @Singleton - fun provideAuthServiceFactory( - retrofitBuilder: RetrofitBuilder, + fun provideAuthServiceFactory(retrofitBuilder: RetrofitBuilder, baseURLStorage: BaseURLStorage, ): ServiceFactory = retrofitBuilder.createServiceFactory(baseURLStorage) + + @Provides + @Singleton + fun provideAccountManager(@ApplicationContext context: Context): AccountManager { + return AccountManager.get(context) + } + + @Provides + @Singleton + fun provideAccountType(@ApplicationContext context: Context) = AccountParameters( + accountType = context.getString(R.string.account_type), + authTokenType = context.getString(R.string.auth_token_type), + ) } @Binds diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt new file mode 100644 index 0000000..bde58f0 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt @@ -0,0 +1,141 @@ +package gq.kirmanak.mealient.service.auth + +import android.accounts.* +import android.content.Context +import android.content.Intent +import android.os.Bundle +import dagger.hilt.android.qualifiers.ApplicationContext +import gq.kirmanak.mealient.data.auth.AuthDataSource +import gq.kirmanak.mealient.data.auth.impl.AuthenticationError +import gq.kirmanak.mealient.ui.auth.AuthenticatorActivity +import kotlinx.coroutines.runBlocking +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AccountAuthenticatorImpl @Inject constructor( + @ApplicationContext private val context: Context, + private val authDataSource: AuthDataSource, + private val accountParameters: AccountParameters, + private val accountManager: AccountManager, +) : AbstractAccountAuthenticator(context) { + + override fun addAccount( + response: AccountAuthenticatorResponse, + accountType: String, + authTokenType: String, + requiredFeatures: Array?, + options: Bundle, + ): Bundle { + Timber.v("addAccount() called with: response = $response, accountType = $accountType, authTokenType = $authTokenType, requiredFeatures = $requiredFeatures, options = $options") + + try { + checkAccountType(accountType) + checkAuthTokenType(authTokenType) + } catch (e: AuthenticatorException) { + Timber.e(e, "addAccount: validation failure") + return e.bundle + } + + val intent = Intent(context, AuthenticatorActivity::class.java) + return Bundle().apply { putParcelable(AccountManager.KEY_INTENT, intent) } + } + + override fun getAuthToken( + response: AccountAuthenticatorResponse, + account: Account, + authTokenType: String, + options: Bundle + ): Bundle { + Timber.v("getAuthToken() called with: response = $response, account = $account, authTokenType = $authTokenType, options = $options") + + val password: String? + val baseUrl: String? + try { + checkAccountType(account.type) + checkAuthTokenType(authTokenType) + password = accountManager.getPassword(account) + ?: throw AuthenticatorException.AccountNotFound(account) + baseUrl = options.getString(KEY_BASE_URL) ?: throw AuthenticatorException.NoBaseUrl + } catch (e: AuthenticatorException) { + Timber.e(e, "getAuthToken: validation failure") + return e.bundle + } + + val token = try { + runBlocking { authDataSource.authenticate(account.name, password, baseUrl) } + } catch (e: RuntimeException) { + return when (e) { + is AuthenticationError.NotMealie -> AuthenticatorException.NotMealie.bundle + is AuthenticationError.Unauthorized -> AuthenticatorException.Unauthorized.bundle + else -> throw NetworkErrorException(e) + } + } + + return Bundle().apply { + putString(AccountManager.KEY_ACCOUNT_NAME, account.name) + putString(AccountManager.KEY_ACCOUNT_TYPE, accountParameters.accountType) + putString(AccountManager.KEY_AUTHTOKEN, token) + } + } + + // region Unsupported operations + override fun confirmCredentials( + response: AccountAuthenticatorResponse?, + account: Account?, + options: Bundle? + ): Bundle { + Timber.v("confirmCredentials() called with: response = $response, account = $account, options = $options") + return AuthenticatorException.UnsupportedOperation("confirmCredentials").bundle + } + + override fun editProperties( + response: AccountAuthenticatorResponse, + accountType: String, + ): Bundle? { + Timber.v("editProperties() called with: response = $response, accountType = $accountType") + response.onError( + AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION, + "editProperties is not supported" + ) + return null + } + + override fun getAuthTokenLabel(authTokenType: String?): String? { + Timber.v("getAuthTokenLabel() called with: authTokenType = $authTokenType") + return null + } + + override fun updateCredentials( + response: AccountAuthenticatorResponse?, + account: Account?, + authTokenType: String?, + options: Bundle? + ): Bundle { + Timber.v("updateCredentials() called with: response = $response, account = $account, authTokenType = $authTokenType, options = $options") + return AuthenticatorException.UnsupportedOperation("updateCredentials").bundle + } + + override fun hasFeatures( + response: AccountAuthenticatorResponse?, + account: Account?, + features: Array? + ): Bundle { + Timber.v("hasFeatures() called with: response = $response, account = $account, features = $features") + return Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, true) } + } + // end region + + private fun checkAccountType(accountType: String) { + if (accountType != accountParameters.accountType) { + throw AuthenticatorException.UnsupportedAccountType(accountType) + } + } + + private fun checkAuthTokenType(authTokenType: String) { + if (authTokenType != accountParameters.accountType) { + throw AuthenticatorException.UnsupportedAccountType(authTokenType) + } + } +} diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt new file mode 100644 index 0000000..8bfaa37 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt @@ -0,0 +1,15 @@ +package gq.kirmanak.mealient.service.auth + +import android.accounts.Account +import kotlinx.coroutines.flow.Flow + +interface AccountManagerInteractor { + + fun getAccounts(): Array + + suspend fun addAccount(): Account + + suspend fun getAuthToken(account: Account): String + + fun accountUpdatesFlow(): Flow> +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt new file mode 100644 index 0000000..03d92e1 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt @@ -0,0 +1,59 @@ +package gq.kirmanak.mealient.service.auth + +import android.accounts.Account +import android.accounts.AccountManager +import android.app.Activity +import dagger.hilt.android.scopes.ActivityScoped +import kotlinx.coroutines.flow.Flow +import timber.log.Timber +import javax.inject.Inject + +@ActivityScoped +class AccountManagerInteractorImpl @Inject constructor( + private val accountManager: AccountManager, + private val accountParameters: AccountParameters, + private val activity: Activity +) : AccountManagerInteractor { + + override fun getAccounts(): Array { + Timber.v("getAccounts() called") + val accounts = accountManager.getAccountsByType(accountParameters.accountType) + Timber.v("getAccounts() returned: $accounts") + return accounts + } + + override suspend fun addAccount(): Account { + Timber.v("addAccount() called") + val bundle = accountManager.addAccount( + accountParameters.accountType, + accountParameters.authTokenType, + null, + null, + activity, + null, + null + ).await() + return bundle.toAccount() + } + + override suspend fun getAuthToken(account: Account): String { + Timber.v("getAuthToken() called with: account = $account") + val bundle = accountManager.getAuthToken( + account, + accountParameters.authTokenType, + null, + activity, + null, + null + ).await() + val receivedAccount = bundle.toAccount() + check(account == receivedAccount) { "Received account ($receivedAccount) differs from requested ($account)" } + val token = bundle.authToken() + Timber.v("getAuthToken() returned: $token") + return token + } + + override fun accountUpdatesFlow(): Flow> { + return accountManager.accountUpdatesFlow(accountParameters.accountType) + } +} diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountParameters.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountParameters.kt new file mode 100644 index 0000000..9a6450c --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountParameters.kt @@ -0,0 +1,6 @@ +package gq.kirmanak.mealient.service.auth + +data class AccountParameters( + val accountType: String, + val authTokenType: String, +) diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt new file mode 100644 index 0000000..f14fb3e --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt @@ -0,0 +1,56 @@ +package gq.kirmanak.mealient.service.auth + +import android.accounts.Account +import android.accounts.AccountManager +import android.accounts.AccountManager.* +import android.accounts.AccountManagerFuture +import android.accounts.OnAccountsUpdateListener +import android.os.Build +import android.os.Bundle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.onFailure +import kotlinx.coroutines.channels.onSuccess +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.withContext +import timber.log.Timber + +internal const val KEY_BASE_URL = "mealientBaseUrl" + +internal suspend fun AccountManagerFuture.await(): T = withContext(Dispatchers.IO) { result } + +internal fun Bundle.toAccount(): Account = Account(accountName(), accountType()) + +internal fun Bundle.accountType(): String = string(KEY_ACCOUNT_TYPE) { "Account type is null" } + +internal fun Bundle.accountName(): String = string(KEY_ACCOUNT_NAME) { "Account name is null" } + +internal fun Bundle.authToken(): String = string(KEY_AUTHTOKEN) { "Auth token is null" } + +private fun Bundle.string(key: String, error: () -> String) = checkNotNull(getString(key), error) + +@OptIn(ExperimentalCoroutinesApi::class) +internal fun AccountManager.accountUpdatesFlow(vararg types: String): Flow> = + callbackFlow { + Timber.v("accountUpdatesFlow() called") + val listener = OnAccountsUpdateListener { accounts -> + Timber.d("accountUpdatesFlow: updated accounts = $accounts") + val filtered = accounts.filter { types.contains(it.type) }.toTypedArray() + Timber.d("accountUpdatesFlow: filtered accounts = $filtered") + trySendBlocking(filtered) + .onSuccess { Timber.d("accountUpdatesFlow: sent accounts update") } + .onFailure { Timber.e(it, "accountUpdatesFlow: failed to send update") } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + addOnAccountsUpdatedListener(listener, null, true, types) + } else { + addOnAccountsUpdatedListener(listener, null, true) + } + awaitClose { + Timber.d("accountUpdatesFlow: cancelled") + removeOnAccountsUpdatedListener(listener) + } + } diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticationService.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticationService.kt new file mode 100644 index 0000000..e59017d --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticationService.kt @@ -0,0 +1,16 @@ +package gq.kirmanak.mealient.service.auth + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class AuthenticationService : Service() { + + @Inject + lateinit var accountAuthenticatorImpl: AccountAuthenticatorImpl + + override fun onBind(intent: Intent?): IBinder? = accountAuthenticatorImpl.iBinder +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt new file mode 100644 index 0000000..3b06440 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt @@ -0,0 +1,52 @@ +package gq.kirmanak.mealient.service.auth + +import android.accounts.Account +import android.accounts.AccountManager +import android.os.Bundle + +sealed class AuthenticatorException( + val bundle: Bundle +) : RuntimeException() { + + constructor(errorCode: Int, errorMessage: String) : this( + Bundle().apply { + putInt(AccountManager.KEY_ERROR_CODE, errorCode) + putString(AccountManager.KEY_ERROR_MESSAGE, errorMessage) + } + ) + + class UnsupportedAuthTokenType(received: String?) : AuthenticatorException( + errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS, + errorMessage = "Received auth token type = $received" + ) + + class UnsupportedAccountType(received: String?) : AuthenticatorException( + errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS, + errorMessage = "Received account type = $received" + ) + + class UnsupportedOperation(operation: String) : AuthenticatorException( + errorCode = AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION, + errorMessage = "$operation is not supported" + ) + + class AccountNotFound(account: Account) : AuthenticatorException( + errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS, + errorMessage = "$account not found" + ) + + object NoBaseUrl : AuthenticatorException( + errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS, + errorMessage = "Base URL was not provided" + ) + + object Unauthorized : AuthenticatorException( + errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS, + errorMessage = "E-mail or password weren't correct" + ) + + object NotMealie : AuthenticatorException( + errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS, + errorMessage = "Base URL must be pointing at a non Mealie server" + ) +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticatorActivity.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticatorActivity.kt new file mode 100644 index 0000000..69692d7 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticatorActivity.kt @@ -0,0 +1,9 @@ +package gq.kirmanak.mealient.ui.auth + +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class AuthenticatorActivity : AppCompatActivity() { + +} \ No newline at end of file diff --git a/app/src/main/res/layout/authenticator_activity.xml b/app/src/main/res/layout/authenticator_activity.xml new file mode 100644 index 0000000..515be2e --- /dev/null +++ b/app/src/main/res/layout/authenticator_activity.xml @@ -0,0 +1,38 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/authenticator_graph.xml b/app/src/main/res/navigation/authenticator_graph.xml new file mode 100644 index 0000000..12e281c --- /dev/null +++ b/app/src/main/res/navigation/authenticator_graph.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file 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/main/res/xml/account_authenticator.xml b/app/src/main/res/xml/account_authenticator.xml new file mode 100644 index 0000000..95d0037 --- /dev/null +++ b/app/src/main/res/xml/account_authenticator.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file From 57f4ec4e22318e0fc62c104c4401dcdbdf136a99 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 5 Apr 2022 16:51:53 +0500 Subject: [PATCH 02/16] Implement the simplest account manager authentication --- .../java/gq/kirmanak/mealient/MainActivity.kt | 1 + .../mealient/data/auth/AuthStorage.kt | 14 ---- .../mealient/data/auth/impl/AuthRepoImpl.kt | 52 +++++++++++--- .../data/auth/impl/AuthStorageImpl.kt | 52 -------------- .../gq/kirmanak/mealient/di/AuthModule.kt | 15 ++-- .../service/auth/AccountAuthenticatorImpl.kt | 72 ++++++++----------- .../service/auth/AccountManagerInteractor.kt | 4 +- .../auth/AccountManagerInteractorImpl.kt | 39 +++++----- .../mealient/service/auth/AuthExtensions.kt | 15 ++-- .../service/auth/AuthenticatorException.kt | 11 ++- .../ui/auth/AuthenticationViewModel.kt | 15 ++-- .../main/res/xml/account_authenticator.xml | 1 - .../data/auth/impl/AuthRepoImplTest.kt | 66 ++--------------- 13 files changed, 124 insertions(+), 233 deletions(-) delete mode 100644 app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt diff --git a/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt b/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt index 1446e96..41b809d 100644 --- a/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt +++ b/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt @@ -74,6 +74,7 @@ class MainActivity : AppCompatActivity() { R.id.logout, R.id.login -> { // When user clicks logout they don't want to be authorized authViewModel.authRequested = item.itemId == R.id.login + authViewModel.logout() true } else -> super.onOptionsItemSelected(item) 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 deleted file mode 100644 index 5955e7f..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt +++ /dev/null @@ -1,14 +0,0 @@ -package gq.kirmanak.mealient.data.auth - -import kotlinx.coroutines.flow.Flow - -interface AuthStorage { - - val authHeaderFlow: Flow - - suspend fun storeAuthData(authHeader: String) - - suspend fun getAuthHeader(): String? - - suspend fun clearAuthData() -} \ 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..790ee4e 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 @@ -1,8 +1,9 @@ package gq.kirmanak.mealient.data.auth.impl -import gq.kirmanak.mealient.data.auth.AuthDataSource +import android.accounts.Account import gq.kirmanak.mealient.data.auth.AuthRepo -import gq.kirmanak.mealient.data.auth.AuthStorage +import gq.kirmanak.mealient.extensions.runCatchingExceptCancel +import gq.kirmanak.mealient.service.auth.AccountManagerInteractor import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import timber.log.Timber @@ -11,28 +12,59 @@ import javax.inject.Singleton @Singleton class AuthRepoImpl @Inject constructor( - private val dataSource: AuthDataSource, - private val storage: AuthStorage, + private val accountManagerInteractor: AccountManagerInteractor, ) : AuthRepo { override val isAuthorizedFlow: Flow - get() = storage.authHeaderFlow.map { it != null } + get() = accountManagerInteractor.accountUpdatesFlow() + .map { it.firstOrNull() } + .map { account -> + runCatchingExceptCancel { getAuthToken(account) } + .onFailure { Timber.e(it, "authHeaderObservable: can't get token") } + .getOrNull() + }.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)) + val account = accountManagerInteractor.addAccount(username, password) + runCatchingExceptCancel { + getAuthToken(account) // Try to get token to check if password is correct + }.onFailure { + Timber.e(it, "authenticate: can't authorize") + removeAccount(account) // Remove account with incorrect password + }.onSuccess { + Timber.d("authenticate: successfully authorized") + }.getOrThrow() // Throw error to show it to user } - override suspend fun getAuthHeader(): String? = storage.getAuthHeader() + override suspend fun getAuthHeader(): String? { + Timber.v("getAuthHeader() called") + return currentAccount() + ?.let { getAuthToken(it) } + ?.let { AUTH_HEADER_FORMAT.format(it) } + } + + private suspend fun getAuthToken(account: Account?): String? { + return account?.let { accountManagerInteractor.getAuthToken(it) } + } + + private fun currentAccount(): Account? { + val account = accountManagerInteractor.getAccounts().firstOrNull() + Timber.v("currentAccount() returned: $account") + return account + } 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() + currentAccount()?.let { removeAccount(it) } + } + + private suspend fun removeAccount(account: Account) { + Timber.v("removeAccount() called with: account = $account") + accountManagerInteractor.removeAccount(account) } 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 deleted file mode 100644 index a15ac30..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt +++ /dev/null @@ -1,52 +0,0 @@ -package gq.kirmanak.mealient.data.auth.impl - -import androidx.datastore.preferences.core.Preferences -import dagger.hilt.android.scopes.ActivityScoped -import gq.kirmanak.mealient.data.auth.AuthStorage -import gq.kirmanak.mealient.data.storage.PreferencesStorage -import gq.kirmanak.mealient.service.auth.AccountManagerInteractor -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import timber.log.Timber -import javax.inject.Inject - -@ActivityScoped -class AuthStorageImpl @Inject constructor( - private val accountManagerInteractorImpl: AccountManagerInteractor, - private val preferencesStorage: PreferencesStorage, -) : AuthStorage { - - private val authHeaderKey: Preferences.Key - get() = preferencesStorage.authHeaderKey - override val authHeaderFlow: Flow - get() = preferencesStorage.valueUpdates(authHeaderKey) - - override suspend fun storeAuthData(authHeader: String) { - Timber.v("storeAuthData() called with: authHeader = $authHeader") - preferencesStorage.storeValues(Pair(authHeaderKey, authHeader)) - } - - override suspend fun getAuthHeader(): String? { - Timber.v("getAuthHeader() called") - val token = preferencesStorage.getValue(authHeaderKey) - Timber.d("getAuthHeader: header is \"$token\"") - return token - } - - override fun authHeaderObservable(): Flow { - Timber.v("authHeaderObservable() called") - return accountManagerInteractorImpl.accountUpdatesFlow() - .map { it.firstOrNull() } - .map { account -> - account ?: return@map null - runCatching { accountManagerInteractorImpl.getAuthToken(account) } - .onFailure { Timber.e(it, "authHeaderObservable: can't get token") } - .getOrNull() - } - } - - override suspend fun clearAuthData() { - Timber.v("clearAuthData() called") - preferencesStorage.removeValues(authHeaderKey) - } -} 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 66f556d..7a5a272 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt @@ -1,22 +1,25 @@ package gq.kirmanak.mealient.di +import android.accounts.AccountManager +import android.content.Context 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.R 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.auth.impl.AuthDataSourceImpl import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl import gq.kirmanak.mealient.data.auth.impl.AuthService -import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl 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 gq.kirmanak.mealient.service.auth.AccountManagerInteractor +import gq.kirmanak.mealient.service.auth.AccountManagerInteractorImpl import gq.kirmanak.mealient.service.auth.AccountParameters import javax.inject.Singleton @@ -26,8 +29,6 @@ interface AuthModule { companion object { - const val ACCOUNT_TYPE = "Mealient" - @Provides @Singleton fun provideAuthServiceFactory(retrofitBuilder: RetrofitBuilder, @@ -54,9 +55,11 @@ interface AuthModule { @Binds @Singleton - fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage + fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo @Binds @Singleton - fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo + fun bindAccountManagerInteractor( + accountManagerInteractorImpl: AccountManagerInteractorImpl + ): AccountManagerInteractor } diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt index bde58f0..74920ca 100644 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt @@ -2,12 +2,12 @@ package gq.kirmanak.mealient.service.auth import android.accounts.* import android.content.Context -import android.content.Intent import android.os.Bundle import dagger.hilt.android.qualifiers.ApplicationContext import gq.kirmanak.mealient.data.auth.AuthDataSource -import gq.kirmanak.mealient.data.auth.impl.AuthenticationError -import gq.kirmanak.mealient.ui.auth.AuthenticatorActivity +import gq.kirmanak.mealient.data.network.NetworkError +import gq.kirmanak.mealient.extensions.runCatchingExceptCancel +import gq.kirmanak.mealient.service.auth.AuthenticatorException.* import kotlinx.coroutines.runBlocking import timber.log.Timber import javax.inject.Inject @@ -21,55 +21,30 @@ class AccountAuthenticatorImpl @Inject constructor( private val accountManager: AccountManager, ) : AbstractAccountAuthenticator(context) { - override fun addAccount( - response: AccountAuthenticatorResponse, - accountType: String, - authTokenType: String, - requiredFeatures: Array?, - options: Bundle, - ): Bundle { - Timber.v("addAccount() called with: response = $response, accountType = $accountType, authTokenType = $authTokenType, requiredFeatures = $requiredFeatures, options = $options") - - try { - checkAccountType(accountType) - checkAuthTokenType(authTokenType) - } catch (e: AuthenticatorException) { - Timber.e(e, "addAccount: validation failure") - return e.bundle - } - - val intent = Intent(context, AuthenticatorActivity::class.java) - return Bundle().apply { putParcelable(AccountManager.KEY_INTENT, intent) } - } - override fun getAuthToken( response: AccountAuthenticatorResponse, account: Account, authTokenType: String, - options: Bundle + options: Bundle? ): Bundle { Timber.v("getAuthToken() called with: response = $response, account = $account, authTokenType = $authTokenType, options = $options") - val password: String? - val baseUrl: String? - try { + val password = try { checkAccountType(account.type) checkAuthTokenType(authTokenType) - password = accountManager.getPassword(account) - ?: throw AuthenticatorException.AccountNotFound(account) - baseUrl = options.getString(KEY_BASE_URL) ?: throw AuthenticatorException.NoBaseUrl + accountManager.getPassword(account) ?: throw AccountNotFound(account) } catch (e: AuthenticatorException) { Timber.e(e, "getAuthToken: validation failure") return e.bundle } - val token = try { - runBlocking { authDataSource.authenticate(account.name, password, baseUrl) } - } catch (e: RuntimeException) { - return when (e) { - is AuthenticationError.NotMealie -> AuthenticatorException.NotMealie.bundle - is AuthenticationError.Unauthorized -> AuthenticatorException.Unauthorized.bundle - else -> throw NetworkErrorException(e) + val token = runCatchingExceptCancel { + runBlocking { authDataSource.authenticate(account.name, password) } + }.getOrElse { + return when (it) { + is NetworkError.NotMealie -> NotMealie.bundle + is NetworkError.Unauthorized -> Unauthorized.bundle + else -> throw NetworkErrorException(it) } } @@ -87,7 +62,18 @@ class AccountAuthenticatorImpl @Inject constructor( options: Bundle? ): Bundle { Timber.v("confirmCredentials() called with: response = $response, account = $account, options = $options") - return AuthenticatorException.UnsupportedOperation("confirmCredentials").bundle + return UnsupportedOperation("confirmCredentials").bundle + } + + override fun addAccount( + response: AccountAuthenticatorResponse, + accountType: String, + authTokenType: String, + requiredFeatures: Array?, + options: Bundle?, + ): Bundle { + Timber.v("addAccount() called with: response = $response, accountType = $accountType, authTokenType = $authTokenType, requiredFeatures = $requiredFeatures, options = $options") + return UnsupportedOperation("addAccount").bundle } override fun editProperties( @@ -114,7 +100,7 @@ class AccountAuthenticatorImpl @Inject constructor( options: Bundle? ): Bundle { Timber.v("updateCredentials() called with: response = $response, account = $account, authTokenType = $authTokenType, options = $options") - return AuthenticatorException.UnsupportedOperation("updateCredentials").bundle + return UnsupportedOperation("updateCredentials").bundle } override fun hasFeatures( @@ -129,13 +115,13 @@ class AccountAuthenticatorImpl @Inject constructor( private fun checkAccountType(accountType: String) { if (accountType != accountParameters.accountType) { - throw AuthenticatorException.UnsupportedAccountType(accountType) + throw UnsupportedAccountType(accountType) } } private fun checkAuthTokenType(authTokenType: String) { - if (authTokenType != accountParameters.accountType) { - throw AuthenticatorException.UnsupportedAccountType(authTokenType) + if (authTokenType != accountParameters.authTokenType) { + throw UnsupportedAuthTokenType(authTokenType) } } } diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt index 8bfaa37..4cd2eea 100644 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt @@ -7,9 +7,11 @@ interface AccountManagerInteractor { fun getAccounts(): Array - suspend fun addAccount(): Account + suspend fun addAccount(email: String, password: String): Account suspend fun getAuthToken(account: Account): String fun accountUpdatesFlow(): Flow> + + suspend fun removeAccount(account: Account) } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt index 03d92e1..3782e73 100644 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt @@ -2,38 +2,30 @@ package gq.kirmanak.mealient.service.auth import android.accounts.Account import android.accounts.AccountManager -import android.app.Activity -import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.flow.Flow import timber.log.Timber import javax.inject.Inject +import javax.inject.Singleton -@ActivityScoped +@Singleton class AccountManagerInteractorImpl @Inject constructor( private val accountManager: AccountManager, private val accountParameters: AccountParameters, - private val activity: Activity ) : AccountManagerInteractor { override fun getAccounts(): Array { Timber.v("getAccounts() called") val accounts = accountManager.getAccountsByType(accountParameters.accountType) - Timber.v("getAccounts() returned: $accounts") + Timber.v("getAccounts() returned: ${accounts.contentToString()}") return accounts } - override suspend fun addAccount(): Account { - Timber.v("addAccount() called") - val bundle = accountManager.addAccount( - accountParameters.accountType, - accountParameters.authTokenType, - null, - null, - activity, - null, - null - ).await() - return bundle.toAccount() + override suspend fun addAccount(email: String, password: String): Account { + Timber.v("addAccount() called with: email = $email, password = $password") + val account = Account(email, accountParameters.accountType) + removeAccount(account) // Remove account if it was created earlier + accountManager.addAccountExplicitly(account, password, null) + return account } override suspend fun getAuthToken(account: Account): String { @@ -42,18 +34,27 @@ class AccountManagerInteractorImpl @Inject constructor( account, accountParameters.authTokenType, null, - activity, + null, null, null ).await() val receivedAccount = bundle.toAccount() - check(account == receivedAccount) { "Received account ($receivedAccount) differs from requested ($account)" } + check(account == receivedAccount) { + "Received account ($receivedAccount) differs from requested ($account)" + } val token = bundle.authToken() Timber.v("getAuthToken() returned: $token") return token } override fun accountUpdatesFlow(): Flow> { + Timber.v("accountUpdatesFlow() called") return accountManager.accountUpdatesFlow(accountParameters.accountType) } + + override suspend fun removeAccount(account: Account) { + Timber.v("removeAccount() called with: account = $account") + val bundle = accountManager.removeAccount(account, null, null, null).await() + Timber.d("removeAccount: result is ${bundle.result()}") + } } diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt index f14fb3e..4fb9fc4 100644 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt @@ -7,19 +7,16 @@ import android.accounts.AccountManagerFuture import android.accounts.OnAccountsUpdateListener import android.os.Build import android.os.Bundle +import gq.kirmanak.mealient.extensions.logErrors import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.channels.onFailure -import kotlinx.coroutines.channels.onSuccess import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.withContext import timber.log.Timber -internal const val KEY_BASE_URL = "mealientBaseUrl" - internal suspend fun AccountManagerFuture.await(): T = withContext(Dispatchers.IO) { result } internal fun Bundle.toAccount(): Account = Account(accountName(), accountType()) @@ -30,6 +27,8 @@ internal fun Bundle.accountName(): String = string(KEY_ACCOUNT_NAME) { "Account internal fun Bundle.authToken(): String = string(KEY_AUTHTOKEN) { "Auth token is null" } +internal fun Bundle.result(): Boolean = getBoolean(KEY_BOOLEAN_RESULT) + private fun Bundle.string(key: String, error: () -> String) = checkNotNull(getString(key), error) @OptIn(ExperimentalCoroutinesApi::class) @@ -37,12 +36,10 @@ internal fun AccountManager.accountUpdatesFlow(vararg types: String): Flow - Timber.d("accountUpdatesFlow: updated accounts = $accounts") + Timber.d("accountUpdatesFlow: updated accounts = ${accounts.contentToString()}") val filtered = accounts.filter { types.contains(it.type) }.toTypedArray() - Timber.d("accountUpdatesFlow: filtered accounts = $filtered") - trySendBlocking(filtered) - .onSuccess { Timber.d("accountUpdatesFlow: sent accounts update") } - .onFailure { Timber.e(it, "accountUpdatesFlow: failed to send update") } + Timber.d("accountUpdatesFlow: filtered accounts = ${filtered.contentToString()}") + trySendBlocking(filtered).logErrors("accountUpdatesFlow") } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { addOnAccountsUpdatedListener(listener, null, true, types) diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt index 3b06440..a22dfea 100644 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt @@ -35,18 +35,15 @@ sealed class AuthenticatorException( errorMessage = "$account not found" ) - object NoBaseUrl : AuthenticatorException( - errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS, - errorMessage = "Base URL was not provided" - ) - object Unauthorized : AuthenticatorException( - errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS, + errorCode = ErrorCode.Unauthorized.ordinal, errorMessage = "E-mail or password weren't correct" ) object NotMealie : AuthenticatorException( - errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS, + errorCode = ErrorCode.NotMealie.ordinal, errorMessage = "Base URL must be pointing at a non Mealie server" ) + + enum class ErrorCode { NotMealie, Unauthorized; } } \ No newline at end of file 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..0826a92 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 @@ -8,7 +8,6 @@ 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 @@ -32,18 +31,14 @@ class AuthenticationViewModel @Inject constructor( var authRequested: Boolean by authRequestsFlow::value var showLoginButton: Boolean by showLoginButtonFlow::value - init { - viewModelScope.launch { - authRequestsFlow.collect { isRequested -> - // Clear auth token on logout request - if (!isRequested) authRepo.logout() - } - } - } - suspend fun authenticate(username: String, password: String) = runCatchingExceptCancel { authRepo.authenticate(username, password) }.onFailure { Timber.e(it, "authenticate: can't authenticate") } + + fun logout() { + Timber.v("logout() called") + viewModelScope.launch { authRepo.logout() } + } } \ No newline at end of file diff --git a/app/src/main/res/xml/account_authenticator.xml b/app/src/main/res/xml/account_authenticator.xml index 95d0037..cdbd336 100644 --- a/app/src/main/res/xml/account_authenticator.xml +++ b/app/src/main/res/xml/account_authenticator.xml @@ -1,6 +1,5 @@ \ No newline at end of file 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..50fac9a 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 @@ -1,78 +1,22 @@ 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.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 gq.kirmanak.mealient.service.auth.AccountManagerInteractor import io.mockk.MockKAnnotations -import io.mockk.coEvery -import io.mockk.coVerify import io.mockk.impl.annotations.MockK -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf -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 - - @MockK(relaxUnitFun = true) - lateinit var storage: AuthStorage + lateinit var accountManagerInteractor: AccountManagerInteractor lateinit var subject: AuthRepoImpl @Before fun setUp() { MockKAnnotations.init(this) - subject = AuthRepoImpl(dataSource, storage) + subject = AuthRepoImpl(accountManagerInteractor) } - @Test - fun `when not authenticated then first auth status is false`() = runTest { - coEvery { storage.authHeaderFlow } returns flowOf(null) - assertThat(subject.isAuthorizedFlow.first()).isFalse() - } - - @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 { - coEvery { dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) } returns TEST_TOKEN - subject.authenticate(TEST_USERNAME, TEST_PASSWORD) - coVerify { storage.storeAuthData(TEST_AUTH_HEADER) } - } - - @Test - fun `when logout then clearAuthData is called`() = runTest { - subject.logout() - coVerify { storage.clearAuthData() } - } + // TODO write the actual tests } From 76a49a41a132f1c5e8c5fa102d0f519418b16305 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 5 Apr 2022 18:29:09 +0500 Subject: [PATCH 03/16] Implement token invalidation --- .../kirmanak/mealient/data/auth/AuthRepo.kt | 2 + .../mealient/data/auth/impl/AuthRepoImpl.kt | 18 +++++- .../data/network/AuthenticationInterceptor.kt | 41 +++++++++++++ .../mealient/data/network/OkHttpBuilder.kt | 14 ++--- .../recipes/network/RecipeDataSourceImpl.kt | 8 +-- .../data/recipes/network/RecipeService.kt | 3 - .../service/auth/AccountAuthenticatorImpl.kt | 11 +++- .../service/auth/AccountManagerInteractor.kt | 2 + .../auth/AccountManagerInteractorImpl.kt | 18 ++++-- .../data/auth/impl/AuthStorageImplTest.kt | 60 ------------------- 10 files changed, 91 insertions(+), 86 deletions(-) create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt delete mode 100644 app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImplTest.kt 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..b1d898d 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 @@ -13,4 +13,6 @@ interface AuthRepo { suspend fun requireAuthHeader(): String suspend fun logout() + + fun invalidateAuthHeader(header: 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 790ee4e..2d15301 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 @@ -37,12 +37,14 @@ class AuthRepoImpl @Inject constructor( }.getOrThrow() // Throw error to show it to user } - override suspend fun getAuthHeader(): String? { + override suspend fun getAuthHeader(): String? = runCatchingExceptCancel { Timber.v("getAuthHeader() called") - return currentAccount() + currentAccount() ?.let { getAuthToken(it) } ?.let { AUTH_HEADER_FORMAT.format(it) } - } + }.onFailure { + Timber.e(it, "getAuthHeader: can't request auth header") + }.getOrNull() private suspend fun getAuthToken(account: Account?): String? { return account?.let { accountManagerInteractor.getAuthToken(it) } @@ -67,6 +69,16 @@ class AuthRepoImpl @Inject constructor( accountManagerInteractor.removeAccount(account) } + override fun invalidateAuthHeader(header: String) { + Timber.v("invalidateAuthHeader() called with: header = $header") + val token = header.substringAfter("Bearer ") + if (token == header) { + Timber.w("invalidateAuthHeader: can't find token in $header") + } else { + accountManagerInteractor.invalidateAuthToken(token) + } + } + companion object { private const val AUTH_HEADER_FORMAT = "Bearer %s" } 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..147f29b --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt @@ -0,0 +1,41 @@ +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) + if (listOf(401, 403).contains(response.code)) { + authRepo.invalidateAuthHeader(currentHeader) + } + val newHeader = authHeader ?: return response + return proceedWithAuthHeader(chain, newHeader) + } + + 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 index feae5e9..325f96f 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/OkHttpBuilder.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/OkHttpBuilder.kt @@ -5,17 +5,17 @@ import okhttp3.OkHttpClient import timber.log.Timber import javax.inject.Inject -class OkHttpBuilder -@Inject -constructor( +class OkHttpBuilder @Inject constructor( // Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382) - private val interceptors: Set<@JvmSuppressWildcards Interceptor> + private val authenticationInterceptor: AuthenticationInterceptor, + private val interceptors: Set<@JvmSuppressWildcards Interceptor>, ) { fun buildOkHttp(): OkHttpClient { Timber.v("buildOkHttp() called") - return OkHttpClient.Builder() - .apply { for (interceptor in interceptors) addNetworkInterceptor(interceptor) } - .build() + return OkHttpClient.Builder().apply { + addInterceptor(authenticationInterceptor) + for (interceptor in interceptors) addNetworkInterceptor(interceptor) + }.build() } } 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/service/auth/AccountAuthenticatorImpl.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt index 74920ca..515bb2a 100644 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt @@ -21,6 +21,11 @@ class AccountAuthenticatorImpl @Inject constructor( private val accountManager: AccountManager, ) : AbstractAccountAuthenticator(context) { + private val accountType: String + get() = accountParameters.accountType + private val authTokenType: String + get() = accountParameters.authTokenType + override fun getAuthToken( response: AccountAuthenticatorResponse, account: Account, @@ -50,7 +55,7 @@ class AccountAuthenticatorImpl @Inject constructor( return Bundle().apply { putString(AccountManager.KEY_ACCOUNT_NAME, account.name) - putString(AccountManager.KEY_ACCOUNT_TYPE, accountParameters.accountType) + putString(AccountManager.KEY_ACCOUNT_TYPE, accountType) putString(AccountManager.KEY_AUTHTOKEN, token) } } @@ -114,13 +119,13 @@ class AccountAuthenticatorImpl @Inject constructor( // end region private fun checkAccountType(accountType: String) { - if (accountType != accountParameters.accountType) { + if (accountType != this.accountType) { throw UnsupportedAccountType(accountType) } } private fun checkAuthTokenType(authTokenType: String) { - if (authTokenType != accountParameters.authTokenType) { + if (authTokenType != this.authTokenType) { throw UnsupportedAuthTokenType(authTokenType) } } diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt index 4cd2eea..3fff004 100644 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt @@ -14,4 +14,6 @@ interface AccountManagerInteractor { fun accountUpdatesFlow(): Flow> suspend fun removeAccount(account: Account) + + fun invalidateAuthToken(token: String) } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt index 3782e73..5e8a5bf 100644 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt @@ -13,16 +13,21 @@ class AccountManagerInteractorImpl @Inject constructor( private val accountParameters: AccountParameters, ) : AccountManagerInteractor { + private val accountType: String + get() = accountParameters.accountType + private val authTokenType: String + get() = accountParameters.authTokenType + override fun getAccounts(): Array { Timber.v("getAccounts() called") - val accounts = accountManager.getAccountsByType(accountParameters.accountType) + val accounts = accountManager.getAccountsByType(accountType) Timber.v("getAccounts() returned: ${accounts.contentToString()}") return accounts } override suspend fun addAccount(email: String, password: String): Account { Timber.v("addAccount() called with: email = $email, password = $password") - val account = Account(email, accountParameters.accountType) + val account = Account(email, accountType) removeAccount(account) // Remove account if it was created earlier accountManager.addAccountExplicitly(account, password, null) return account @@ -32,7 +37,7 @@ class AccountManagerInteractorImpl @Inject constructor( Timber.v("getAuthToken() called with: account = $account") val bundle = accountManager.getAuthToken( account, - accountParameters.authTokenType, + authTokenType, null, null, null, @@ -49,7 +54,7 @@ class AccountManagerInteractorImpl @Inject constructor( override fun accountUpdatesFlow(): Flow> { Timber.v("accountUpdatesFlow() called") - return accountManager.accountUpdatesFlow(accountParameters.accountType) + return accountManager.accountUpdatesFlow(accountType) } override suspend fun removeAccount(account: Account) { @@ -57,4 +62,9 @@ class AccountManagerInteractorImpl @Inject constructor( val bundle = accountManager.removeAccount(account, null, null, null).await() Timber.d("removeAccount: result is ${bundle.result()}") } + + override fun invalidateAuthToken(token: String) { + Timber.v("resetAuthToken() called with: token = $token") + accountManager.invalidateAuthToken(accountType, token) + } } 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 deleted file mode 100644 index 77e2ea6..0000000 --- a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImplTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package gq.kirmanak.mealient.data.auth.impl - -import com.google.common.truth.Truth.assertThat -import dagger.hilt.android.testing.HiltAndroidTest -import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER -import gq.kirmanak.mealient.test.HiltRobolectricTest -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.runTest -import org.junit.Test -import javax.inject.Inject - -@OptIn(ExperimentalCoroutinesApi::class) -@HiltAndroidTest -class AuthStorageImplTest : HiltRobolectricTest() { - - @Inject - lateinit var subject: AuthStorageImpl - - @Test - fun `when storing auth data then doesn't throw`() = runTest { - subject.storeAuthData(TEST_AUTH_HEADER) - } - - @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) - 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() - } - - @Test - fun `when clearAuthData then getToken returns null`() = runTest { - subject.storeAuthData(TEST_AUTH_HEADER) - subject.clearAuthData() - assertThat(subject.getAuthHeader()).isNull() - } -} \ No newline at end of file From b3f75278849867ea1345c74ae5e18000953e4036 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 5 Apr 2022 18:41:39 +0500 Subject: [PATCH 04/16] Add authentication timeout --- .../mealient/service/auth/AccountAuthenticatorImpl.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt index 515bb2a..e374f6b 100644 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt @@ -9,6 +9,7 @@ import gq.kirmanak.mealient.data.network.NetworkError import gq.kirmanak.mealient.extensions.runCatchingExceptCancel import gq.kirmanak.mealient.service.auth.AuthenticatorException.* import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -44,7 +45,11 @@ class AccountAuthenticatorImpl @Inject constructor( } val token = runCatchingExceptCancel { - runBlocking { authDataSource.authenticate(account.name, password) } + runBlocking { + withTimeout(10000) { + authDataSource.authenticate(account.name, password) + } + } }.getOrElse { return when (it) { is NetworkError.NotMealie -> NotMealie.bundle From d40793104fd8689963f9166f0389b3b276ed6d75 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 5 Apr 2022 19:09:37 +0500 Subject: [PATCH 05/16] Fix recursive calls to getAuthToken --- .../data/auth/impl/AuthDataSourceImpl.kt | 2 +- .../mealient/data/network/OkHttpBuilder.kt | 21 -------------- .../mealient/data/network/RetrofitBuilder.kt | 11 ++++++-- .../data/network/RetrofitServiceFactory.kt | 21 +++++++++----- .../mealient/data/network/ServiceFactory.kt | 2 +- .../gq/kirmanak/mealient/di/NetworkModule.kt | 28 +++++++++++++++++-- .../mealient/ui/picasso/PicassoBuilder.kt | 4 ++- 7 files changed, 52 insertions(+), 37 deletions(-) delete mode 100644 app/src/main/java/gq/kirmanak/mealient/data/network/OkHttpBuilder.kt diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt index 0fe2951..67e12d9 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt @@ -23,7 +23,7 @@ class AuthDataSourceImpl @Inject constructor( override suspend fun authenticate(username: String, password: String): String { Timber.v("authenticate() called with: username = $username, password = $password") - val authService = authServiceFactory.provideService() + val authService = authServiceFactory.provideService(needAuth = false) val response = sendRequest(authService, username, password) val accessToken = parseToken(response) Timber.v("authenticate() returned: $accessToken") 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 325f96f..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 authenticationInterceptor: AuthenticationInterceptor, - private val interceptors: Set<@JvmSuppressWildcards Interceptor>, -) { - - fun buildOkHttp(): OkHttpClient { - Timber.v("buildOkHttp() called") - return OkHttpClient.Builder().apply { - addInterceptor(authenticationInterceptor) - 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..fc61647 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 @@ -1,6 +1,8 @@ package gq.kirmanak.mealient.data.network import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import gq.kirmanak.mealient.di.AUTH_OK_HTTP +import gq.kirmanak.mealient.di.NO_AUTH_OK_HTTP import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType @@ -8,22 +10,25 @@ import okhttp3.OkHttpClient import retrofit2.Retrofit import timber.log.Timber import javax.inject.Inject +import javax.inject.Named import javax.inject.Singleton @Singleton class RetrofitBuilder @Inject constructor( - private val okHttpClient: OkHttpClient, + @Named(AUTH_OK_HTTP) private val authOkHttpClient: OkHttpClient, + @Named(NO_AUTH_OK_HTTP) private val noAuthOkHttpClient: OkHttpClient, private val json: Json ) { @OptIn(ExperimentalSerializationApi::class) - fun buildRetrofit(baseUrl: String): Retrofit { + fun buildRetrofit(baseUrl: String, needAuth: Boolean): Retrofit { Timber.v("buildRetrofit() called with: baseUrl = $baseUrl") val contentType = "application/json".toMediaType() val converterFactory = json.asConverterFactory(contentType) + val client = if (needAuth) authOkHttpClient else noAuthOkHttpClient return Retrofit.Builder() .baseUrl(baseUrl) - .client(okHttpClient) + .client(client) .addConverterFactory(converterFactory) .build() } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt index 7a7ea95..d112685 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt @@ -13,21 +13,28 @@ class RetrofitServiceFactory( private val baseURLStorage: BaseURLStorage, ) : ServiceFactory { - private val cache: MutableMap = mutableMapOf() + private val cache: MutableMap = mutableMapOf() - override suspend fun provideService(baseUrl: String?): T = runCatchingExceptCancel { + override suspend fun provideService( + baseUrl: String?, + needAuth: Boolean, + ): T = runCatchingExceptCancel { Timber.v("provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}") val url = baseUrl ?: baseURLStorage.requireBaseURL() - synchronized(cache) { cache[url] ?: createService(url, serviceClass) } + val params = ServiceParams(url, needAuth) + synchronized(cache) { cache[params] ?: createService(params, serviceClass) } }.getOrElse { Timber.e(it, "provideService: can't provide service for $baseUrl") throw NetworkError.MalformedUrl(it) } - private fun createService(url: String, serviceClass: Class): T { - Timber.v("createService() called with: url = $url, serviceClass = ${serviceClass.simpleName}") - val service = retrofitBuilder.buildRetrofit(url).create(serviceClass) - cache[url] = service + private fun createService(serviceParams: ServiceParams, serviceClass: Class): T { + Timber.v("createService() called with: serviceParams = $serviceParams, serviceClass = ${serviceClass.simpleName}") + val (url, needAuth) = serviceParams + val service = retrofitBuilder.buildRetrofit(url, needAuth).create(serviceClass) + cache[serviceParams] = service return service } + + data class ServiceParams(val baseUrl: String, val needAuth: Boolean) } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt index e46c2a8..0ab4a78 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt @@ -2,5 +2,5 @@ package gq.kirmanak.mealient.data.network interface ServiceFactory { - suspend fun provideService(baseUrl: String? = null): T + suspend fun provideService(baseUrl: String? = null, needAuth: Boolean = true): T } \ No newline at end of file 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/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 { From b129913a82d944f13ff9c5a1da35f0ba9ed18c91 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 5 Apr 2022 19:20:39 +0500 Subject: [PATCH 06/16] Clear unused and fix tests --- .../data/storage/PreferencesStorage.kt | 2 - .../data/storage/PreferencesStorageImpl.kt | 2 - .../gq/kirmanak/mealient/di/AuthModule.kt | 3 +- .../mealient/ui/auth/AuthenticatorActivity.kt | 9 ----- .../res/layout/authenticator_activity.xml | 38 ------------------- .../res/navigation/authenticator_graph.xml | 13 ------- .../data/auth/impl/AuthDataSourceImplTest.kt | 6 ++- .../network/RetrofitServiceFactoryTest.kt | 8 ++-- .../storage/PreferencesStorageImplTest.kt | 18 ++++----- 9 files changed, 19 insertions(+), 80 deletions(-) delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticatorActivity.kt delete mode 100644 app/src/main/res/layout/authenticator_activity.xml delete mode 100644 app/src/main/res/navigation/authenticator_graph.xml 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 7a5a272..ac5d54d 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt @@ -31,7 +31,8 @@ interface AuthModule { @Provides @Singleton - fun provideAuthServiceFactory(retrofitBuilder: RetrofitBuilder, + fun provideAuthServiceFactory( + retrofitBuilder: RetrofitBuilder, baseURLStorage: BaseURLStorage, ): ServiceFactory = retrofitBuilder.createServiceFactory(baseURLStorage) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticatorActivity.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticatorActivity.kt deleted file mode 100644 index 69692d7..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticatorActivity.kt +++ /dev/null @@ -1,9 +0,0 @@ -package gq.kirmanak.mealient.ui.auth - -import androidx.appcompat.app.AppCompatActivity -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class AuthenticatorActivity : AppCompatActivity() { - -} \ No newline at end of file diff --git a/app/src/main/res/layout/authenticator_activity.xml b/app/src/main/res/layout/authenticator_activity.xml deleted file mode 100644 index 515be2e..0000000 --- a/app/src/main/res/layout/authenticator_activity.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/authenticator_graph.xml b/app/src/main/res/navigation/authenticator_graph.xml deleted file mode 100644 index 12e281c..0000000 --- a/app/src/main/res/navigation/authenticator_graph.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - \ 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..10a405f 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(), eq(false)) } 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(), eq(false)) + } throws MalformedUrl(RuntimeException()) callAuthenticate() } 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..aacd515 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 @@ -33,7 +33,7 @@ class RetrofitServiceFactoryTest { fun setUp() { MockKAnnotations.init(this) subject = retrofitBuilder.createServiceFactory(baseURLStorage) - coEvery { retrofitBuilder.buildRetrofit(any()) } returns retrofit + coEvery { retrofitBuilder.buildRetrofit(any(), eq(true)) } returns retrofit every { retrofit.create(eq(VersionService::class.java)) } returns versionService coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL } @@ -53,7 +53,7 @@ class RetrofitServiceFactoryTest { fun `when provideService called twice then builder called once`() = runTest { subject.provideService() subject.provideService() - coVerifyAll { retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL)) } + coVerifyAll { retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL), eq(true)) } } @Test @@ -61,8 +61,8 @@ class RetrofitServiceFactoryTest { subject.provideService() subject.provideService("new url") coVerifyAll { - retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL)) - retrofitBuilder.buildRetrofit(eq("new url")) + retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL), eq(true)) + retrofitBuilder.buildRetrofit(eq("new url"), eq(true)) } } } \ No newline at end of file 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 From d1578afab9b36e03e8bf68923cb3581a1cd745b4 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 5 Apr 2022 22:25:51 +0500 Subject: [PATCH 07/16] Fix AuthenticationInterceptor calling getAuthHeader twice --- .../data/network/AuthenticationInterceptor.kt | 2 + .../network/AuthenticationInterceptorTest.kt | 121 ++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 app/src/test/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptorTest.kt 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 index 147f29b..b2fc79a 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt @@ -20,6 +20,8 @@ class AuthenticationInterceptor @Inject constructor( val response = proceedWithAuthHeader(chain, currentHeader) if (listOf(401, 403).contains(response.code)) { authRepo.invalidateAuthHeader(currentHeader) + } else { + return response } val newHeader = authHeader ?: return response return proceedWithAuthHeader(chain, newHeader) 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..ef86aef --- /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(TEST_AUTH_HEADER) + 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 From ba28f7d32266f55acda7d20586e96ab00297992c Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Wed, 6 Apr 2022 15:00:27 +0500 Subject: [PATCH 08/16] Start AddAccountActivity implementation --- .../ui/addaccount/AddAccountActivity.kt | 19 ++++++ .../ui/addaccount/AddAccountFragment.kt | 64 +++++++++++++++++++ .../ui/addaccount/AddAccountViewModel.kt | 20 ++++++ 3 files changed, 103 insertions(+) create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountActivity.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountFragment.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountViewModel.kt diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountActivity.kt b/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountActivity.kt new file mode 100644 index 0000000..c96b43b --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountActivity.kt @@ -0,0 +1,19 @@ +package gq.kirmanak.mealient.ui.addaccount + +import android.os.Bundle +import android.os.PersistableBundle +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint +import gq.kirmanak.mealient.R + +@AndroidEntryPoint +class AddAccountActivity : AppCompatActivity() { + + private val viewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { + super.onCreate(savedInstanceState, persistentState) + supportActionBar?.title = getString(R.string.app_name) + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountFragment.kt new file mode 100644 index 0000000..8509a2c --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountFragment.kt @@ -0,0 +1,64 @@ +package gq.kirmanak.mealient.ui.addaccount + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import by.kirich1409.viewbindingdelegate.viewBinding +import dagger.hilt.android.AndroidEntryPoint +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 kotlinx.coroutines.launch +import timber.log.Timber + +@AndroidEntryPoint +class AddAccountFragment : Fragment(R.layout.fragment_authentication) { + + private val binding by viewBinding(FragmentAuthenticationBinding::bind) + private val viewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState") + binding.button.setOnClickListener { onLoginClicked() } + } + + private fun onLoginClicked(): Unit = with(binding) { + Timber.v("onLoginClicked() called") + + val email: String = emailInput.checkIfInputIsEmpty( + inputLayout = emailInputLayout, + lifecycleOwner = viewLifecycleOwner, + stringId = R.string.fragment_authentication_email_input_empty, + ) ?: return + + val pass: String = passwordInput.checkIfInputIsEmpty( + inputLayout = passwordInputLayout, + lifecycleOwner = viewLifecycleOwner, + stringId = R.string.fragment_authentication_password_input_empty, + trim = false, + ) ?: return + + button.isClickable = false + viewLifecycleOwner.lifecycleScope.launch { + onAuthenticationResult(viewModel.authenticate(email, pass)) + } + } + + private fun onAuthenticationResult(result: Result) { + Timber.v("onAuthenticationResult() called with: result = $result") + if (result.isSuccess) { + TODO("Implement authentication success") + } + + binding.passwordInputLayout.error = when (result.exceptionOrNull()) { + is NetworkError.Unauthorized -> getString(R.string.fragment_authentication_credentials_incorrect) + else -> null + } + + binding.button.isClickable = true + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountViewModel.kt new file mode 100644 index 0000000..8e87894 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountViewModel.kt @@ -0,0 +1,20 @@ +package gq.kirmanak.mealient.ui.addaccount + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import gq.kirmanak.mealient.data.auth.AuthRepo +import gq.kirmanak.mealient.extensions.runCatchingExceptCancel +import timber.log.Timber + +@HiltViewModel +class AddAccountViewModel( + private val authRepo: AuthRepo, +) : ViewModel() { + + suspend fun authenticate(username: String, password: String) = runCatchingExceptCancel { + Timber.v("authenticate() called with: username = $username, password = $password") + authRepo.authenticate(username, password) + }.onFailure { + Timber.e(it, "authenticate: can't authenticate") + } +} \ No newline at end of file From 7c081c199a125b2e930dd08710c68cfdbc33c6f9 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Fri, 8 Apr 2022 20:00:53 +0500 Subject: [PATCH 09/16] Replace AccountManager with EncryptedSharedPreferences --- app/build.gradle | 3 + app/src/main/AndroidManifest.xml | 31 ++-- .../java/gq/kirmanak/mealient/MainActivity.kt | 15 +- .../kirmanak/mealient/data/auth/AuthRepo.kt | 4 +- .../mealient/data/auth/AuthStorage.kt | 20 +++ .../data/auth/impl/AuthDataSourceImpl.kt | 2 +- .../mealient/data/auth/impl/AuthRepoImpl.kt | 78 +++------- .../data/auth/impl/AuthStorageImpl.kt | 64 ++++++++ .../data/network/AuthenticationInterceptor.kt | 2 +- .../mealient/data/network/RetrofitBuilder.kt | 16 +- .../data/network/RetrofitServiceFactory.kt | 21 +-- .../mealient/data/network/ServiceFactory.kt | 2 +- .../gq/kirmanak/mealient/di/AuthModule.kt | 42 ++++-- .../gq/kirmanak/mealient/di/BaseURLModule.kt | 10 +- .../gq/kirmanak/mealient/di/RecipeModule.kt | 10 +- .../mealient/extensions/FragmentExtensions.kt | 7 +- .../mealient/extensions/ViewExtensions.kt | 14 ++ .../service/auth/AccountAuthenticatorImpl.kt | 137 ------------------ .../service/auth/AccountManagerInteractor.kt | 19 --- .../auth/AccountManagerInteractorImpl.kt | 70 --------- .../service/auth/AccountParameters.kt | 6 - .../mealient/service/auth/AuthExtensions.kt | 53 ------- .../service/auth/AuthenticationService.kt | 16 -- .../service/auth/AuthenticatorException.kt | 49 ------- .../ui/addaccount/AddAccountActivity.kt | 19 --- .../ui/addaccount/AddAccountFragment.kt | 64 -------- .../ui/addaccount/AddAccountViewModel.kt | 20 --- .../ui/auth/AuthenticationFragment.kt | 14 +- .../mealient/ui/auth/AuthenticationState.kt | 5 +- .../ui/auth/AuthenticationViewModel.kt | 7 +- .../mealient/ui/baseurl/BaseURLFragment.kt | 12 +- .../mealient/ui/baseurl/BaseURLScreenState.kt | 8 - .../mealient/ui/baseurl/BaseURLViewModel.kt | 30 +--- .../mealient/ui/recipes/RecipesFragment.kt | 14 -- app/src/main/res/navigation/nav_graph.xml | 9 +- .../main/res/xml/account_authenticator.xml | 5 - .../data/auth/impl/AuthDataSourceImplTest.kt | 4 +- .../data/auth/impl/AuthRepoImplTest.kt | 22 --- .../network/AuthenticationInterceptorTest.kt | 2 +- .../network/RetrofitServiceFactoryTest.kt | 8 +- .../ui/baseurl/BaseURLViewModelTest.kt | 31 ---- 41 files changed, 243 insertions(+), 722 deletions(-) create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/service/auth/AccountParameters.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticationService.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountActivity.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountFragment.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountViewModel.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenState.kt delete mode 100644 app/src/main/res/xml/account_authenticator.xml delete mode 100644 app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt 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 49dab32..26f6c08 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,27 +15,16 @@ android:roundIcon="@mipmap/ic_launcher" android:supportsRtl="true" tools:ignore="UnusedAttribute" - android:theme="@style/Theme.Mealient"> - - - + android:theme="@style/Theme.Mealient"> + + + - - - - - - - - - - - + + + + \ 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/MainActivity.kt index 41b809d..eaf1669 100644 --- a/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt +++ b/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt @@ -5,6 +5,8 @@ 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 @@ -71,9 +73,11 @@ 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 -> { authViewModel.logout() true } @@ -81,4 +85,9 @@ class MainActivity : AppCompatActivity() { } 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/data/auth/AuthRepo.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt index b1d898d..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,7 +6,7 @@ interface AuthRepo { val isAuthorizedFlow: Flow - suspend fun authenticate(username: String, password: String) + suspend fun authenticate(email: String, password: String) suspend fun getAuthHeader(): String? @@ -14,5 +14,5 @@ interface AuthRepo { suspend fun logout() - fun invalidateAuthHeader(header: String) + 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 new file mode 100644 index 0000000..8486e6a --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt @@ -0,0 +1,20 @@ +package gq.kirmanak.mealient.data.auth + +import kotlinx.coroutines.flow.Flow + +interface AuthStorage { + + val authHeaderFlow: Flow + + suspend fun setAuthHeader(authHeader: String?) + + suspend fun getAuthHeader(): String? + + 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/AuthDataSourceImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt index 67e12d9..0fe2951 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt @@ -23,7 +23,7 @@ class AuthDataSourceImpl @Inject constructor( override suspend fun authenticate(username: String, password: String): String { Timber.v("authenticate() called with: username = $username, password = $password") - val authService = authServiceFactory.provideService(needAuth = false) + val authService = authServiceFactory.provideService() val response = sendRequest(authService, username, password) val accessToken = parseToken(response) Timber.v("authenticate() returned: $accessToken") 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 2d15301..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 @@ -1,9 +1,9 @@ package gq.kirmanak.mealient.data.auth.impl -import android.accounts.Account +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 gq.kirmanak.mealient.service.auth.AccountManagerInteractor import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import timber.log.Timber @@ -12,71 +12,41 @@ import javax.inject.Singleton @Singleton class AuthRepoImpl @Inject constructor( - private val accountManagerInteractor: AccountManagerInteractor, + private val authStorage: AuthStorage, + private val authDataSource: AuthDataSource, ) : AuthRepo { override val isAuthorizedFlow: Flow - get() = accountManagerInteractor.accountUpdatesFlow() - .map { it.firstOrNull() } - .map { account -> - runCatchingExceptCancel { getAuthToken(account) } - .onFailure { Timber.e(it, "authHeaderObservable: can't get token") } - .getOrNull() - }.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 account = accountManagerInteractor.addAccount(username, password) - runCatchingExceptCancel { - getAuthToken(account) // Try to get token to check if password is correct - }.onFailure { - Timber.e(it, "authenticate: can't authorize") - removeAccount(account) // Remove account with incorrect password - }.onSuccess { - Timber.d("authenticate: successfully authorized") - }.getOrThrow() // Throw error to show it to user + 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? = runCatchingExceptCancel { - Timber.v("getAuthHeader() called") - currentAccount() - ?.let { getAuthToken(it) } - ?.let { AUTH_HEADER_FORMAT.format(it) } - }.onFailure { - Timber.e(it, "getAuthHeader: can't request auth header") - }.getOrNull() + override suspend fun getAuthHeader(): String? = authStorage.getAuthHeader() - private suspend fun getAuthToken(account: Account?): String? { - return account?.let { accountManagerInteractor.getAuthToken(it) } + override suspend fun requireAuthHeader(): String = checkNotNull(getAuthHeader()) { + "Auth header is null when it was required" } - private fun currentAccount(): Account? { - val account = accountManagerInteractor.getAccounts().firstOrNull() - Timber.v("currentAccount() returned: $account") - return account - } - - override suspend fun requireAuthHeader(): String = - checkNotNull(getAuthHeader()) { "Auth header is null when it was required" } - override suspend fun logout() { Timber.v("logout() called") - currentAccount()?.let { removeAccount(it) } + authStorage.setEmail(null) + authStorage.setPassword(null) + authStorage.setAuthHeader(null) } - private suspend fun removeAccount(account: Account) { - Timber.v("removeAccount() called with: account = $account") - accountManagerInteractor.removeAccount(account) - } - - override fun invalidateAuthHeader(header: String) { - Timber.v("invalidateAuthHeader() called with: header = $header") - val token = header.substringAfter("Bearer ") - if (token == header) { - Timber.w("invalidateAuthHeader: can't find token in $header") - } else { - accountManagerInteractor.invalidateAuthToken(token) - } + 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 new file mode 100644 index 0000000..49bc744 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt @@ -0,0 +1,64 @@ +package gq.kirmanak.mealient.data.auth.impl + +import android.content.SharedPreferences +import androidx.core.content.edit +import gq.kirmanak.mealient.data.auth.AuthStorage +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( + @Named(ENCRYPTED) private val sharedPreferences: SharedPreferences, +) : AuthStorage { + + override val authHeaderFlow: Flow + get() = sharedPreferences + .prefsChangeFlow { getString(AUTH_HEADER_KEY, null) } + .distinctUntilChanged() + private val singleThreadDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + + 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 { + value?.let { putString(key, value) } ?: remove(key) + } + } + + private suspend fun getString(key: String) = withContext(singleThreadDispatcher) { + val result = sharedPreferences.getString(key, null) + Timber.v("getString() called with: key = $key, returned: $result") + result + } + + companion object { + private const val AUTH_HEADER_KEY = "authHeader" + private const val EMAIL_KEY = "email" + private const val PASSWORD_KEY = "password" + } +} \ No newline at end of file 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 index b2fc79a..5eb4bf7 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt @@ -19,7 +19,7 @@ class AuthenticationInterceptor @Inject constructor( val currentHeader = authHeader ?: return chain.proceed(chain.request()) val response = proceedWithAuthHeader(chain, currentHeader) if (listOf(401, 403).contains(response.code)) { - authRepo.invalidateAuthHeader(currentHeader) + runBlocking { authRepo.invalidateAuthHeader() } } else { return response } 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 fc61647..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 @@ -1,34 +1,26 @@ package gq.kirmanak.mealient.data.network import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory -import gq.kirmanak.mealient.di.AUTH_OK_HTTP -import gq.kirmanak.mealient.di.NO_AUTH_OK_HTTP import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import retrofit2.Retrofit import timber.log.Timber -import javax.inject.Inject -import javax.inject.Named -import javax.inject.Singleton -@Singleton -class RetrofitBuilder @Inject constructor( - @Named(AUTH_OK_HTTP) private val authOkHttpClient: OkHttpClient, - @Named(NO_AUTH_OK_HTTP) private val noAuthOkHttpClient: OkHttpClient, +class RetrofitBuilder( + private val okHttpClient: OkHttpClient, private val json: Json ) { @OptIn(ExperimentalSerializationApi::class) - fun buildRetrofit(baseUrl: String, needAuth: Boolean): Retrofit { + fun buildRetrofit(baseUrl: String): Retrofit { Timber.v("buildRetrofit() called with: baseUrl = $baseUrl") val contentType = "application/json".toMediaType() val converterFactory = json.asConverterFactory(contentType) - val client = if (needAuth) authOkHttpClient else noAuthOkHttpClient return Retrofit.Builder() .baseUrl(baseUrl) - .client(client) + .client(okHttpClient) .addConverterFactory(converterFactory) .build() } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt index d112685..7a7ea95 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt @@ -13,28 +13,21 @@ class RetrofitServiceFactory( private val baseURLStorage: BaseURLStorage, ) : ServiceFactory { - private val cache: MutableMap = mutableMapOf() + private val cache: MutableMap = mutableMapOf() - override suspend fun provideService( - baseUrl: String?, - needAuth: Boolean, - ): T = runCatchingExceptCancel { + override suspend fun provideService(baseUrl: String?): T = runCatchingExceptCancel { Timber.v("provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}") val url = baseUrl ?: baseURLStorage.requireBaseURL() - val params = ServiceParams(url, needAuth) - synchronized(cache) { cache[params] ?: createService(params, serviceClass) } + synchronized(cache) { cache[url] ?: createService(url, serviceClass) } }.getOrElse { Timber.e(it, "provideService: can't provide service for $baseUrl") throw NetworkError.MalformedUrl(it) } - private fun createService(serviceParams: ServiceParams, serviceClass: Class): T { - Timber.v("createService() called with: serviceParams = $serviceParams, serviceClass = ${serviceClass.simpleName}") - val (url, needAuth) = serviceParams - val service = retrofitBuilder.buildRetrofit(url, needAuth).create(serviceClass) - cache[serviceParams] = service + private fun createService(url: String, serviceClass: Class): T { + Timber.v("createService() called with: url = $url, serviceClass = ${serviceClass.simpleName}") + val service = retrofitBuilder.buildRetrofit(url).create(serviceClass) + cache[url] = service return service } - - data class ServiceParams(val baseUrl: String, val needAuth: Boolean) } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt index 0ab4a78..e46c2a8 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt @@ -2,5 +2,5 @@ package gq.kirmanak.mealient.data.network interface ServiceFactory { - suspend fun provideService(baseUrl: String? = null, needAuth: Boolean = true): T + suspend fun provideService(baseUrl: String? = null): T } \ No newline at end of file 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 ac5d54d..cb87c5f 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt @@ -2,25 +2,29 @@ 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.R 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.auth.impl.AuthDataSourceImpl import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl import gq.kirmanak.mealient.data.auth.impl.AuthService +import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl 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 gq.kirmanak.mealient.service.auth.AccountManagerInteractor -import gq.kirmanak.mealient.service.auth.AccountManagerInteractorImpl -import gq.kirmanak.mealient.service.auth.AccountParameters +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import javax.inject.Named import javax.inject.Singleton @Module @@ -28,13 +32,17 @@ 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 @@ -44,10 +52,20 @@ interface AuthModule { @Provides @Singleton - fun provideAccountType(@ApplicationContext context: Context) = AccountParameters( - accountType = context.getString(R.string.account_type), - authTokenType = context.getString(R.string.auth_token_type), - ) + @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 @@ -60,7 +78,5 @@ interface AuthModule { @Binds @Singleton - fun bindAccountManagerInteractor( - accountManagerInteractorImpl: AccountManagerInteractorImpl - ): AccountManagerInteractor + 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..805535b 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt @@ -9,6 +9,9 @@ import gq.kirmanak.mealient.data.baseurl.* 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 +23,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/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..7e26a99 100644 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt @@ -4,6 +4,7 @@ import androidx.activity.OnBackPressedDispatcher import androidx.activity.addCallback import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow @@ -33,4 +34,8 @@ fun OnBackPressedDispatcher.backPressedFlow(): Flow = callbackFlow { inline fun Fragment.collectWithViewLifecycle( flow: Flow, crossinline collector: suspend (T) -> Unit, -) = viewLifecycleOwner.lifecycleScope.launch { flow.collect(collector) } +) = launchWithViewLifecycle { flow.collect(collector) } + +fun Fragment.launchWithViewLifecycle( + block: suspend CoroutineScope.() -> Unit, +) = viewLifecycleOwner.lifecycleScope.launch(block = block) 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..3c8a9eb 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,17 @@ 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 { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, _ -> + val value = prefs.valueReader() + trySend(value).logErrors("prefsChangeFlow") + } + trySend(valueReader()) + registerOnSharedPreferenceChangeListener(listener) + awaitClose { unregisterOnSharedPreferenceChangeListener(listener) } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt deleted file mode 100644 index e374f6b..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt +++ /dev/null @@ -1,137 +0,0 @@ -package gq.kirmanak.mealient.service.auth - -import android.accounts.* -import android.content.Context -import android.os.Bundle -import dagger.hilt.android.qualifiers.ApplicationContext -import gq.kirmanak.mealient.data.auth.AuthDataSource -import gq.kirmanak.mealient.data.network.NetworkError -import gq.kirmanak.mealient.extensions.runCatchingExceptCancel -import gq.kirmanak.mealient.service.auth.AuthenticatorException.* -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class AccountAuthenticatorImpl @Inject constructor( - @ApplicationContext private val context: Context, - private val authDataSource: AuthDataSource, - private val accountParameters: AccountParameters, - private val accountManager: AccountManager, -) : AbstractAccountAuthenticator(context) { - - private val accountType: String - get() = accountParameters.accountType - private val authTokenType: String - get() = accountParameters.authTokenType - - override fun getAuthToken( - response: AccountAuthenticatorResponse, - account: Account, - authTokenType: String, - options: Bundle? - ): Bundle { - Timber.v("getAuthToken() called with: response = $response, account = $account, authTokenType = $authTokenType, options = $options") - - val password = try { - checkAccountType(account.type) - checkAuthTokenType(authTokenType) - accountManager.getPassword(account) ?: throw AccountNotFound(account) - } catch (e: AuthenticatorException) { - Timber.e(e, "getAuthToken: validation failure") - return e.bundle - } - - val token = runCatchingExceptCancel { - runBlocking { - withTimeout(10000) { - authDataSource.authenticate(account.name, password) - } - } - }.getOrElse { - return when (it) { - is NetworkError.NotMealie -> NotMealie.bundle - is NetworkError.Unauthorized -> Unauthorized.bundle - else -> throw NetworkErrorException(it) - } - } - - return Bundle().apply { - putString(AccountManager.KEY_ACCOUNT_NAME, account.name) - putString(AccountManager.KEY_ACCOUNT_TYPE, accountType) - putString(AccountManager.KEY_AUTHTOKEN, token) - } - } - - // region Unsupported operations - override fun confirmCredentials( - response: AccountAuthenticatorResponse?, - account: Account?, - options: Bundle? - ): Bundle { - Timber.v("confirmCredentials() called with: response = $response, account = $account, options = $options") - return UnsupportedOperation("confirmCredentials").bundle - } - - override fun addAccount( - response: AccountAuthenticatorResponse, - accountType: String, - authTokenType: String, - requiredFeatures: Array?, - options: Bundle?, - ): Bundle { - Timber.v("addAccount() called with: response = $response, accountType = $accountType, authTokenType = $authTokenType, requiredFeatures = $requiredFeatures, options = $options") - return UnsupportedOperation("addAccount").bundle - } - - override fun editProperties( - response: AccountAuthenticatorResponse, - accountType: String, - ): Bundle? { - Timber.v("editProperties() called with: response = $response, accountType = $accountType") - response.onError( - AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION, - "editProperties is not supported" - ) - return null - } - - override fun getAuthTokenLabel(authTokenType: String?): String? { - Timber.v("getAuthTokenLabel() called with: authTokenType = $authTokenType") - return null - } - - override fun updateCredentials( - response: AccountAuthenticatorResponse?, - account: Account?, - authTokenType: String?, - options: Bundle? - ): Bundle { - Timber.v("updateCredentials() called with: response = $response, account = $account, authTokenType = $authTokenType, options = $options") - return UnsupportedOperation("updateCredentials").bundle - } - - override fun hasFeatures( - response: AccountAuthenticatorResponse?, - account: Account?, - features: Array? - ): Bundle { - Timber.v("hasFeatures() called with: response = $response, account = $account, features = $features") - return Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, true) } - } - // end region - - private fun checkAccountType(accountType: String) { - if (accountType != this.accountType) { - throw UnsupportedAccountType(accountType) - } - } - - private fun checkAuthTokenType(authTokenType: String) { - if (authTokenType != this.authTokenType) { - throw UnsupportedAuthTokenType(authTokenType) - } - } -} diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt deleted file mode 100644 index 3fff004..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt +++ /dev/null @@ -1,19 +0,0 @@ -package gq.kirmanak.mealient.service.auth - -import android.accounts.Account -import kotlinx.coroutines.flow.Flow - -interface AccountManagerInteractor { - - fun getAccounts(): Array - - suspend fun addAccount(email: String, password: String): Account - - suspend fun getAuthToken(account: Account): String - - fun accountUpdatesFlow(): Flow> - - suspend fun removeAccount(account: Account) - - fun invalidateAuthToken(token: String) -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt deleted file mode 100644 index 5e8a5bf..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt +++ /dev/null @@ -1,70 +0,0 @@ -package gq.kirmanak.mealient.service.auth - -import android.accounts.Account -import android.accounts.AccountManager -import kotlinx.coroutines.flow.Flow -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class AccountManagerInteractorImpl @Inject constructor( - private val accountManager: AccountManager, - private val accountParameters: AccountParameters, -) : AccountManagerInteractor { - - private val accountType: String - get() = accountParameters.accountType - private val authTokenType: String - get() = accountParameters.authTokenType - - override fun getAccounts(): Array { - Timber.v("getAccounts() called") - val accounts = accountManager.getAccountsByType(accountType) - Timber.v("getAccounts() returned: ${accounts.contentToString()}") - return accounts - } - - override suspend fun addAccount(email: String, password: String): Account { - Timber.v("addAccount() called with: email = $email, password = $password") - val account = Account(email, accountType) - removeAccount(account) // Remove account if it was created earlier - accountManager.addAccountExplicitly(account, password, null) - return account - } - - override suspend fun getAuthToken(account: Account): String { - Timber.v("getAuthToken() called with: account = $account") - val bundle = accountManager.getAuthToken( - account, - authTokenType, - null, - null, - null, - null - ).await() - val receivedAccount = bundle.toAccount() - check(account == receivedAccount) { - "Received account ($receivedAccount) differs from requested ($account)" - } - val token = bundle.authToken() - Timber.v("getAuthToken() returned: $token") - return token - } - - override fun accountUpdatesFlow(): Flow> { - Timber.v("accountUpdatesFlow() called") - return accountManager.accountUpdatesFlow(accountType) - } - - override suspend fun removeAccount(account: Account) { - Timber.v("removeAccount() called with: account = $account") - val bundle = accountManager.removeAccount(account, null, null, null).await() - Timber.d("removeAccount: result is ${bundle.result()}") - } - - override fun invalidateAuthToken(token: String) { - Timber.v("resetAuthToken() called with: token = $token") - accountManager.invalidateAuthToken(accountType, token) - } -} diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountParameters.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountParameters.kt deleted file mode 100644 index 9a6450c..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountParameters.kt +++ /dev/null @@ -1,6 +0,0 @@ -package gq.kirmanak.mealient.service.auth - -data class AccountParameters( - val accountType: String, - val authTokenType: String, -) diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt deleted file mode 100644 index 4fb9fc4..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt +++ /dev/null @@ -1,53 +0,0 @@ -package gq.kirmanak.mealient.service.auth - -import android.accounts.Account -import android.accounts.AccountManager -import android.accounts.AccountManager.* -import android.accounts.AccountManagerFuture -import android.accounts.OnAccountsUpdateListener -import android.os.Build -import android.os.Bundle -import gq.kirmanak.mealient.extensions.logErrors -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.channels.trySendBlocking -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.withContext -import timber.log.Timber - -internal suspend fun AccountManagerFuture.await(): T = withContext(Dispatchers.IO) { result } - -internal fun Bundle.toAccount(): Account = Account(accountName(), accountType()) - -internal fun Bundle.accountType(): String = string(KEY_ACCOUNT_TYPE) { "Account type is null" } - -internal fun Bundle.accountName(): String = string(KEY_ACCOUNT_NAME) { "Account name is null" } - -internal fun Bundle.authToken(): String = string(KEY_AUTHTOKEN) { "Auth token is null" } - -internal fun Bundle.result(): Boolean = getBoolean(KEY_BOOLEAN_RESULT) - -private fun Bundle.string(key: String, error: () -> String) = checkNotNull(getString(key), error) - -@OptIn(ExperimentalCoroutinesApi::class) -internal fun AccountManager.accountUpdatesFlow(vararg types: String): Flow> = - callbackFlow { - Timber.v("accountUpdatesFlow() called") - val listener = OnAccountsUpdateListener { accounts -> - Timber.d("accountUpdatesFlow: updated accounts = ${accounts.contentToString()}") - val filtered = accounts.filter { types.contains(it.type) }.toTypedArray() - Timber.d("accountUpdatesFlow: filtered accounts = ${filtered.contentToString()}") - trySendBlocking(filtered).logErrors("accountUpdatesFlow") - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - addOnAccountsUpdatedListener(listener, null, true, types) - } else { - addOnAccountsUpdatedListener(listener, null, true) - } - awaitClose { - Timber.d("accountUpdatesFlow: cancelled") - removeOnAccountsUpdatedListener(listener) - } - } diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticationService.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticationService.kt deleted file mode 100644 index e59017d..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticationService.kt +++ /dev/null @@ -1,16 +0,0 @@ -package gq.kirmanak.mealient.service.auth - -import android.app.Service -import android.content.Intent -import android.os.IBinder -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -@AndroidEntryPoint -class AuthenticationService : Service() { - - @Inject - lateinit var accountAuthenticatorImpl: AccountAuthenticatorImpl - - override fun onBind(intent: Intent?): IBinder? = accountAuthenticatorImpl.iBinder -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt deleted file mode 100644 index a22dfea..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt +++ /dev/null @@ -1,49 +0,0 @@ -package gq.kirmanak.mealient.service.auth - -import android.accounts.Account -import android.accounts.AccountManager -import android.os.Bundle - -sealed class AuthenticatorException( - val bundle: Bundle -) : RuntimeException() { - - constructor(errorCode: Int, errorMessage: String) : this( - Bundle().apply { - putInt(AccountManager.KEY_ERROR_CODE, errorCode) - putString(AccountManager.KEY_ERROR_MESSAGE, errorMessage) - } - ) - - class UnsupportedAuthTokenType(received: String?) : AuthenticatorException( - errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS, - errorMessage = "Received auth token type = $received" - ) - - class UnsupportedAccountType(received: String?) : AuthenticatorException( - errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS, - errorMessage = "Received account type = $received" - ) - - class UnsupportedOperation(operation: String) : AuthenticatorException( - errorCode = AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION, - errorMessage = "$operation is not supported" - ) - - class AccountNotFound(account: Account) : AuthenticatorException( - errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS, - errorMessage = "$account not found" - ) - - object Unauthorized : AuthenticatorException( - errorCode = ErrorCode.Unauthorized.ordinal, - errorMessage = "E-mail or password weren't correct" - ) - - object NotMealie : AuthenticatorException( - errorCode = ErrorCode.NotMealie.ordinal, - errorMessage = "Base URL must be pointing at a non Mealie server" - ) - - enum class ErrorCode { NotMealie, Unauthorized; } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountActivity.kt b/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountActivity.kt deleted file mode 100644 index c96b43b..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountActivity.kt +++ /dev/null @@ -1,19 +0,0 @@ -package gq.kirmanak.mealient.ui.addaccount - -import android.os.Bundle -import android.os.PersistableBundle -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import dagger.hilt.android.AndroidEntryPoint -import gq.kirmanak.mealient.R - -@AndroidEntryPoint -class AddAccountActivity : AppCompatActivity() { - - private val viewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { - super.onCreate(savedInstanceState, persistentState) - supportActionBar?.title = getString(R.string.app_name) - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountFragment.kt deleted file mode 100644 index 8509a2c..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountFragment.kt +++ /dev/null @@ -1,64 +0,0 @@ -package gq.kirmanak.mealient.ui.addaccount - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import by.kirich1409.viewbindingdelegate.viewBinding -import dagger.hilt.android.AndroidEntryPoint -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 kotlinx.coroutines.launch -import timber.log.Timber - -@AndroidEntryPoint -class AddAccountFragment : Fragment(R.layout.fragment_authentication) { - - private val binding by viewBinding(FragmentAuthenticationBinding::bind) - private val viewModel by activityViewModels() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState") - binding.button.setOnClickListener { onLoginClicked() } - } - - private fun onLoginClicked(): Unit = with(binding) { - Timber.v("onLoginClicked() called") - - val email: String = emailInput.checkIfInputIsEmpty( - inputLayout = emailInputLayout, - lifecycleOwner = viewLifecycleOwner, - stringId = R.string.fragment_authentication_email_input_empty, - ) ?: return - - val pass: String = passwordInput.checkIfInputIsEmpty( - inputLayout = passwordInputLayout, - lifecycleOwner = viewLifecycleOwner, - stringId = R.string.fragment_authentication_password_input_empty, - trim = false, - ) ?: return - - button.isClickable = false - viewLifecycleOwner.lifecycleScope.launch { - onAuthenticationResult(viewModel.authenticate(email, pass)) - } - } - - private fun onAuthenticationResult(result: Result) { - Timber.v("onAuthenticationResult() called with: result = $result") - if (result.isSuccess) { - TODO("Implement authentication success") - } - - binding.passwordInputLayout.error = when (result.exceptionOrNull()) { - is NetworkError.Unauthorized -> getString(R.string.fragment_authentication_credentials_incorrect) - else -> null - } - - binding.button.isClickable = true - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountViewModel.kt deleted file mode 100644 index 8e87894..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountViewModel.kt +++ /dev/null @@ -1,20 +0,0 @@ -package gq.kirmanak.mealient.ui.addaccount - -import androidx.lifecycle.ViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import gq.kirmanak.mealient.data.auth.AuthRepo -import gq.kirmanak.mealient.extensions.runCatchingExceptCancel -import timber.log.Timber - -@HiltViewModel -class AddAccountViewModel( - private val authRepo: AuthRepo, -) : ViewModel() { - - suspend fun authenticate(username: String, password: String) = runCatchingExceptCancel { - Timber.v("authenticate() called with: username = $username, password = $password") - 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/auth/AuthenticationFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt index 820ae22..ef84b24 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 @@ -5,7 +5,6 @@ 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.navigation.fragment.findNavController import by.kirich1409.viewbindingdelegate.viewBinding import dagger.hilt.android.AndroidEntryPoint @@ -13,8 +12,7 @@ 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 gq.kirmanak.mealient.extensions.launchWithViewLifecycle import timber.log.Timber @AndroidEntryPoint @@ -22,12 +20,6 @@ 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 } - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState") @@ -53,9 +45,7 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) { ) ?: return button.isClickable = false - viewLifecycleOwner.lifecycleScope.launch { - onAuthenticationResult(viewModel.authenticate(email, pass)) - } + launchWithViewLifecycle { onAuthenticationResult(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..45ae5cc 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; 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 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 0826a92..542cefb 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 @@ -18,21 +18,18 @@ 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 - suspend fun authenticate(username: String, password: String) = runCatchingExceptCancel { - authRepo.authenticate(username, password) + suspend fun authenticate(email: String, password: String) = runCatchingExceptCancel { + authRepo.authenticate(email, password) }.onFailure { Timber.e(it, "authenticate: can't authenticate") } 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..8edeb96 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 @@ -11,6 +11,7 @@ import gq.kirmanak.mealient.R import gq.kirmanak.mealient.data.network.NetworkError import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty +import gq.kirmanak.mealient.extensions.launchWithViewLifecycle import timber.log.Timber @AndroidEntryPoint @@ -22,7 +23,6 @@ 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) } @@ -33,16 +33,16 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) { lifecycleOwner = viewLifecycleOwner, stringId = R.string.fragment_baseurl_url_input_empty, ) ?: return - viewModel.saveBaseUrl(url) + launchWithViewLifecycle { onCheckURLResult(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..e5aac34 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 @@ -1,14 +1,10 @@ package gq.kirmanak.mealient.ui.baseurl -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -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 kotlinx.coroutines.launch +import gq.kirmanak.mealient.extensions.runCatchingExceptCancel import timber.log.Timber import javax.inject.Inject @@ -18,35 +14,21 @@ 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 - - fun saveBaseUrl(baseURL: String) { + suspend fun saveBaseUrl(baseURL: String): Result { Timber.v("saveBaseUrl() called with: baseURL = $baseURL") val hasPrefix = ALLOWED_PREFIXES.any { baseURL.startsWith(it) } val url = baseURL.takeIf { hasPrefix } ?: WITH_PREFIX_FORMAT.format(baseURL) - viewModelScope.launch { checkBaseURL(url) } + return checkBaseURL(url) } - private suspend fun checkBaseURL(baseURL: String) { + private suspend fun checkBaseURL(baseURL: String): Result { 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 } - Timber.d("checkBaseURL: version is $version") baseURLStorage.storeBaseURL(baseURL) - currentScreenState = BaseURLScreenState(null, true) + return result.map { } } companion object { 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..f7dc93e 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,7 +14,6 @@ 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 timber.log.Timber @@ -24,19 +23,6 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) { 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()) - } - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState") diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 4a6c092..8af9d4c 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -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"> + + - diff --git a/app/src/main/res/xml/account_authenticator.xml b/app/src/main/res/xml/account_authenticator.xml deleted file mode 100644 index cdbd336..0000000 --- a/app/src/main/res/xml/account_authenticator.xml +++ /dev/null @@ -1,5 +0,0 @@ - - \ 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 10a405f..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(any(), eq(false)) } returns authService + coEvery { authServiceFactory.provideService(any()) } returns authService } @Test @@ -72,7 +72,7 @@ class AuthDataSourceImplTest { @Test(expected = MalformedUrl::class) fun `when authenticate and provideService throws then MalformedUrl`() = runTest { coEvery { - authServiceFactory.provideService(any(), eq(false)) + 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 deleted file mode 100644 index 50fac9a..0000000 --- a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package gq.kirmanak.mealient.data.auth.impl - -import gq.kirmanak.mealient.service.auth.AccountManagerInteractor -import io.mockk.MockKAnnotations -import io.mockk.impl.annotations.MockK -import org.junit.Before - -class AuthRepoImplTest { - - @MockK - lateinit var accountManagerInteractor: AccountManagerInteractor - - lateinit var subject: AuthRepoImpl - - @Before - fun setUp() { - MockKAnnotations.init(this) - subject = AuthRepoImpl(accountManagerInteractor) - } - - // TODO write the actual tests -} 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 index ef86aef..e8419dd 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptorTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptorTest.kt @@ -66,7 +66,7 @@ class AuthenticationInterceptorTest { coVerifySequence { authRepo.getAuthHeader() - authRepo.invalidateAuthHeader(TEST_AUTH_HEADER) + authRepo.invalidateAuthHeader() authRepo.getAuthHeader() } } 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 aacd515..2fc42c0 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 @@ -33,7 +33,7 @@ class RetrofitServiceFactoryTest { fun setUp() { MockKAnnotations.init(this) subject = retrofitBuilder.createServiceFactory(baseURLStorage) - coEvery { retrofitBuilder.buildRetrofit(any(), eq(true)) } returns retrofit + coEvery { retrofitBuilder.buildRetrofit(any()) } returns retrofit every { retrofit.create(eq(VersionService::class.java)) } returns versionService coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL } @@ -53,7 +53,7 @@ class RetrofitServiceFactoryTest { fun `when provideService called twice then builder called once`() = runTest { subject.provideService() subject.provideService() - coVerifyAll { retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL), eq(true)) } + coVerifyAll { retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL)) } } @Test @@ -61,8 +61,8 @@ class RetrofitServiceFactoryTest { subject.provideService() subject.provideService("new url") coVerifyAll { - retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL), eq(true)) - retrofitBuilder.buildRetrofit(eq("new url"), eq(true)) + retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL)) + retrofitBuilder.buildRetrofit(eq("new url")) } } } \ 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 { From 0a854947a90b54f853b22b9965d4d170809eab0b Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Fri, 8 Apr 2022 20:10:13 +0500 Subject: [PATCH 10/16] Move base url implementation to impl package --- .../java/gq/kirmanak/mealient/data/baseurl/VersionInfo.kt | 2 +- .../mealient/data/baseurl/{ => impl}/BaseURLStorageImpl.kt | 3 ++- .../data/baseurl/{ => impl}/VersionDataSourceImpl.kt | 4 +++- .../mealient/data/baseurl/{ => impl}/VersionResponse.kt | 4 ++-- .../mealient/data/baseurl/{ => impl}/VersionService.kt | 2 +- app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt | 6 +++++- .../kirmanak/mealient/extensions/RemoteToLocalMappings.kt | 2 +- .../mealient/data/baseurl/BaseURLStorageImplTest.kt | 1 + .../mealient/data/baseurl/VersionDataSourceImplTest.kt | 3 +++ .../mealient/data/network/RetrofitServiceFactoryTest.kt | 2 +- 10 files changed, 20 insertions(+), 9 deletions(-) rename app/src/main/java/gq/kirmanak/mealient/data/baseurl/{ => impl}/BaseURLStorageImpl.kt (88%) rename app/src/main/java/gq/kirmanak/mealient/data/baseurl/{ => impl}/VersionDataSourceImpl.kt (85%) rename app/src/main/java/gq/kirmanak/mealient/data/baseurl/{ => impl}/VersionResponse.kt (86%) rename app/src/main/java/gq/kirmanak/mealient/data/baseurl/{ => impl}/VersionService.kt (73%) 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/di/BaseURLModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt index 805535b..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,7 +5,11 @@ 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 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/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/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 From 5b56ff9932737b7316574157ab10ef90324906ad Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Fri, 8 Apr 2022 20:15:02 +0500 Subject: [PATCH 11/16] Remove unused methods --- .../mealient/extensions/FragmentExtensions.kt | 24 ------------------- 1 file changed, 24 deletions(-) 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 7e26a99..5e2ee1b 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.CoroutineScope -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, From d2029438d7a019ded5d4c1a2b29ca90ce6a33477 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Fri, 8 Apr 2022 21:19:05 +0500 Subject: [PATCH 12/16] Fix handling result in BaseURLFragment and AuthenticationFragment --- app/src/main/AndroidManifest.xml | 2 +- .../{ => ui/activity}/MainActivity.kt | 10 +++--- .../ui/activity/MainActivityViewModel.kt | 34 +++++++++++++++++++ .../ui/auth/AuthenticationFragment.kt | 8 ++--- .../mealient/ui/auth/AuthenticationState.kt | 4 +-- .../ui/auth/AuthenticationViewModel.kt | 32 ++++++----------- .../mealient/ui/baseurl/BaseURLFragment.kt | 4 +-- .../mealient/ui/baseurl/BaseURLViewModel.kt | 18 +++++++--- .../mealient/ui/recipes/RecipesFragment.kt | 8 ++--- app/src/main/res/layout/main_activity.xml | 2 +- 10 files changed, 77 insertions(+), 45 deletions(-) rename app/src/main/java/gq/kirmanak/mealient/{ => ui/activity}/MainActivity.kt (92%) create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivityViewModel.kt 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/MainActivity.kt b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt similarity index 92% 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 eaf1669..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,4 +1,4 @@ -package gq.kirmanak.mealient +package gq.kirmanak.mealient.ui.activity import android.os.Bundle import android.view.Menu @@ -10,17 +10,17 @@ 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?) { @@ -53,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) { @@ -78,7 +78,7 @@ class MainActivity : AppCompatActivity() { true } R.id.logout -> { - authViewModel.logout() + viewModel.logout() true } else -> super.onOptionsItemSelected(item) 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..34fdf1e --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivityViewModel.kt @@ -0,0 +1,34 @@ +package gq.kirmanak.mealient.ui.activity + +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 = 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 ef84b24..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,7 +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.fragment.app.viewModels import androidx.navigation.fragment.findNavController import by.kirich1409.viewbindingdelegate.viewBinding import dagger.hilt.android.AndroidEntryPoint @@ -12,13 +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.launchWithViewLifecycle import timber.log.Timber @AndroidEntryPoint class AuthenticationFragment : Fragment(R.layout.fragment_authentication) { private val binding by viewBinding(FragmentAuthenticationBinding::bind) - private val viewModel by activityViewModels() + private val viewModel by viewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -26,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) { @@ -45,7 +45,7 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) { ) ?: return button.isClickable = false - launchWithViewLifecycle { 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 45ae5cc..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 @@ -5,7 +5,7 @@ import timber.log.Timber enum class AuthenticationState { AUTHORIZED, UNAUTHORIZED, - UNKNOWN; + HIDDEN; companion object { @@ -15,7 +15,7 @@ enum class AuthenticationState { ): AuthenticationState { Timber.v("determineState() called with: showLoginButton = $showLoginButton, isAuthorized = $isAuthorized") val result = when { - !showLoginButton -> UNKNOWN + !showLoginButton -> HIDDEN isAuthorized -> AUTHORIZED else -> UNAUTHORIZED } 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 542cefb..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,14 +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.combine import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -18,24 +16,16 @@ class AuthenticationViewModel @Inject constructor( private val authRepo: AuthRepo, ) : ViewModel() { - private val showLoginButtonFlow = MutableStateFlow(false) - private val authenticationStateFlow = combine( - showLoginButtonFlow, - authRepo.isAuthorizedFlow, - AuthenticationState::determineState - ) - val authenticationStateLive: LiveData - get() = authenticationStateFlow.asLiveData() - var showLoginButton: Boolean by showLoginButtonFlow::value + private val _authenticationResult = MutableLiveData>() + val authenticationResult: LiveData> + get() = _authenticationResult - suspend fun authenticate(email: String, password: String) = runCatchingExceptCancel { - authRepo.authenticate(email, password) - }.onFailure { - Timber.e(it, "authenticate: can't authenticate") - } - - fun logout() { - Timber.v("logout() called") - viewModelScope.launch { authRepo.logout() } + fun authenticate(email: String, password: String) { + Timber.v("authenticate() called with: email = $email, password = $password") + viewModelScope.launch { + _authenticationResult.value = runCatchingExceptCancel { + authRepo.authenticate(email, password) + } + } } } \ 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 8edeb96..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 @@ -11,7 +11,6 @@ import gq.kirmanak.mealient.R import gq.kirmanak.mealient.data.network.NetworkError import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty -import gq.kirmanak.mealient.extensions.launchWithViewLifecycle import timber.log.Timber @AndroidEntryPoint @@ -24,6 +23,7 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) { super.onViewCreated(view, savedInstanceState) Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState") binding.button.setOnClickListener(::onProceedClick) + viewModel.checkURLResult.observe(viewLifecycleOwner, ::onCheckURLResult) } private fun onProceedClick(view: View) { @@ -33,7 +33,7 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) { lifecycleOwner = viewLifecycleOwner, stringId = R.string.fragment_baseurl_url_input_empty, ) ?: return - launchWithViewLifecycle { onCheckURLResult(viewModel.saveBaseUrl(url)) } + viewModel.saveBaseUrl(url) } private fun onCheckURLResult(result: Result) { 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 e5aac34..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 @@ -1,10 +1,14 @@ package gq.kirmanak.mealient.ui.baseurl +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +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.extensions.runCatchingExceptCancel +import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -14,21 +18,25 @@ class BaseURLViewModel @Inject constructor( private val versionDataSource: VersionDataSource, ) : ViewModel() { - suspend fun saveBaseUrl(baseURL: String): Result { + private val _checkURLResult = MutableLiveData>() + val checkURLResult: LiveData> get() = _checkURLResult + + fun saveBaseUrl(baseURL: String) { Timber.v("saveBaseUrl() called with: baseURL = $baseURL") val hasPrefix = ALLOWED_PREFIXES.any { baseURL.startsWith(it) } val url = baseURL.takeIf { hasPrefix } ?: WITH_PREFIX_FORMAT.format(baseURL) - return checkBaseURL(url) + viewModelScope.launch { checkBaseURL(url) } } - private suspend fun checkBaseURL(baseURL: String): Result { + private suspend fun checkBaseURL(baseURL: String) { Timber.v("checkBaseURL() called with: baseURL = $baseURL") val result = runCatchingExceptCancel { // If it returns proper version info then it must be a Mealie versionDataSource.getVersionInfo(baseURL) + baseURLStorage.storeBaseURL(baseURL) } - baseURLStorage.storeBaseURL(baseURL) - return result.map { } + Timber.i("checkBaseURL: result is $result") + _checkURLResult.value = result } companion object { 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 f7dc93e..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,19 +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.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() + 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 } @@ -64,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"> Date: Fri, 8 Apr 2022 21:25:14 +0500 Subject: [PATCH 13/16] Fix restarting count down in disclaimer --- .../mealient/ui/disclaimer/DisclaimerViewModel.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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) } From a3d320bb79924d9a8a1632730b332b0954e23df8 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Fri, 8 Apr 2022 21:54:43 +0500 Subject: [PATCH 14/16] Fix logout button --- .../kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt | 8 ++------ .../mealient/ui/activity/MainActivityViewModel.kt | 4 +++- 2 files changed, 5 insertions(+), 7 deletions(-) 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 49bc744..ec5ab65 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 @@ -26,9 +26,7 @@ class AuthStorageImpl @Inject constructor( .distinctUntilChanged() private val singleThreadDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() - override suspend fun setAuthHeader(authHeader: String?) { - putString(AUTH_HEADER_KEY, authHeader) - } + override suspend fun setAuthHeader(authHeader: String?) = putString(AUTH_HEADER_KEY, authHeader) override suspend fun getAuthHeader(): String? = getString(AUTH_HEADER_KEY) @@ -45,9 +43,7 @@ class AuthStorageImpl @Inject constructor( value: String? ) = withContext(singleThreadDispatcher) { Timber.v("putString() called with: key = $key, value = $value") - sharedPreferences.edit { - value?.let { putString(key, value) } ?: remove(key) - } + sharedPreferences.edit(commit = true) { putString(key, value) } } private suspend fun getString(key: String) = withContext(singleThreadDispatcher) { 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 index 34fdf1e..6815084 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivityViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivityViewModel.kt @@ -1,5 +1,6 @@ package gq.kirmanak.mealient.ui.activity +import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope @@ -25,7 +26,8 @@ class MainActivityViewModel @Inject constructor( authRepo.isAuthorizedFlow, AuthenticationState::determineState ) - val authenticationStateLive = authenticationStateFlow.asLiveData() + val authenticationStateLive: LiveData + get() = authenticationStateFlow.asLiveData() fun logout() { Timber.v("logout() called") From 869fa21e3f8ba466ed82d2e11219e3b1f1485313 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Fri, 8 Apr 2022 22:08:21 +0500 Subject: [PATCH 15/16] Make code more readable --- .../mealient/data/network/AuthenticationInterceptor.kt | 8 ++++---- .../gq/kirmanak/mealient/extensions/FragmentExtensions.kt | 7 +------ .../gq/kirmanak/mealient/extensions/ViewExtensions.kt | 8 +++----- 3 files changed, 8 insertions(+), 15 deletions(-) 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 index 5eb4bf7..c2466df 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt @@ -18,13 +18,13 @@ class AuthenticationInterceptor @Inject constructor( override fun intercept(chain: Interceptor.Chain): Response { val currentHeader = authHeader ?: return chain.proceed(chain.request()) val response = proceedWithAuthHeader(chain, currentHeader) - if (listOf(401, 403).contains(response.code)) { + 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 { - return response + response } - val newHeader = authHeader ?: return response - return proceedWithAuthHeader(chain, newHeader) } private fun proceedWithAuthHeader( 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 5e2ee1b..b597b95 100644 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt @@ -2,7 +2,6 @@ package gq.kirmanak.mealient.extensions import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch @@ -10,8 +9,4 @@ import kotlinx.coroutines.launch inline fun Fragment.collectWithViewLifecycle( flow: Flow, crossinline collector: suspend (T) -> Unit, -) = launchWithViewLifecycle { flow.collect(collector) } - -fun Fragment.launchWithViewLifecycle( - block: suspend CoroutineScope.() -> Unit, -) = viewLifecycleOwner.lifecycleScope.launch(block = block) +) = viewLifecycleOwner.lifecycleScope.launch { flow.collect(collector) } \ No newline at end of file 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 3c8a9eb..56d2b72 100644 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt @@ -113,11 +113,9 @@ suspend fun EditText.waitUntilNotEmpty() { fun SharedPreferences.prefsChangeFlow( valueReader: SharedPreferences.() -> T, ): Flow = callbackFlow { - val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, _ -> - val value = prefs.valueReader() - trySend(value).logErrors("prefsChangeFlow") - } - trySend(valueReader()) + fun sendValue() = trySend(valueReader()).logErrors("prefsChangeFlow") + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> sendValue() } + sendValue() registerOnSharedPreferenceChangeListener(listener) awaitClose { unregisterOnSharedPreferenceChangeListener(listener) } } \ No newline at end of file From ad1077510b0c26d59a05c641c6a257b0fee5ef4a Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Fri, 8 Apr 2022 23:03:36 +0500 Subject: [PATCH 16/16] Add auth repo and storage tests --- .../data/auth/impl/AuthStorageImpl.kt | 12 +- .../data/auth/impl/AuthRepoImplTest.kt | 108 ++++++++++++++++++ .../data/auth/impl/AuthStorageImplTest.kt | 64 +++++++++++ 3 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt create mode 100644 app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImplTest.kt 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 ec5ab65..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,6 +1,7 @@ package gq.kirmanak.mealient.data.auth.impl import android.content.SharedPreferences +import androidx.annotation.VisibleForTesting import androidx.core.content.edit import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.di.AuthModule.Companion.ENCRYPTED @@ -53,8 +54,13 @@ class AuthStorageImpl @Inject constructor( } companion object { - private const val AUTH_HEADER_KEY = "authHeader" - private const val EMAIL_KEY = "email" - private const val PASSWORD_KEY = "password" + @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/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt new file mode 100644 index 0000000..562dd64 --- /dev/null +++ b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt @@ -0,0 +1,108 @@ +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.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 io.mockk.* +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +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 { + + @MockK + lateinit var dataSource: AuthDataSource + + @MockK(relaxUnitFun = true) + lateinit var storage: AuthStorage + + lateinit var subject: AuthRepo + + @Before + fun setUp() { + MockKAnnotations.init(this) + subject = AuthRepoImpl(storage, dataSource) + } + + @Test + 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 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) + coVerifyAll { + storage.setAuthHeader(TEST_AUTH_HEADER) + storage.setEmail(TEST_USERNAME) + storage.setPassword(TEST_PASSWORD) + } + confirmVerified(storage) + } + + @Test + 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 new file mode 100644 index 0000000..b19f22a --- /dev/null +++ b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImplTest.kt @@ -0,0 +1,64 @@ +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 + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +class AuthStorageImplTest : HiltRobolectricTest() { + + @Inject + @ApplicationContext + lateinit var context: Context + + lateinit var subject: AuthStorage + + lateinit var sharedPreferences: SharedPreferences + + @Before + fun setUp() { + sharedPreferences = context.getSharedPreferences("test", Context.MODE_PRIVATE) + subject = AuthStorageImpl(sharedPreferences) + } + + @Test + 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 authHeader is observed then sends null if nothing saved`() = runTest { + assertThat(subject.authHeaderFlow.first()).isEqualTo(null) + } + + @Test + 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