From 096b5389bd92400c289915f697d5a2a49b88ea78 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Sun, 3 Apr 2022 16:11:24 +0500 Subject: [PATCH] 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