Implement the simplest account manager authentication
This commit is contained in:
@@ -74,6 +74,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
R.id.logout, R.id.login -> {
|
R.id.logout, R.id.login -> {
|
||||||
// When user clicks logout they don't want to be authorized
|
// When user clicks logout they don't want to be authorized
|
||||||
authViewModel.authRequested = item.itemId == R.id.login
|
authViewModel.authRequested = item.itemId == R.id.login
|
||||||
|
authViewModel.logout()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
package gq.kirmanak.mealient.data.auth
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
interface AuthStorage {
|
|
||||||
|
|
||||||
val authHeaderFlow: Flow<String?>
|
|
||||||
|
|
||||||
suspend fun storeAuthData(authHeader: String)
|
|
||||||
|
|
||||||
suspend fun getAuthHeader(): String?
|
|
||||||
|
|
||||||
suspend fun clearAuthData()
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
package gq.kirmanak.mealient.data.auth.impl
|
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.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.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@@ -11,28 +12,59 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class AuthRepoImpl @Inject constructor(
|
class AuthRepoImpl @Inject constructor(
|
||||||
private val dataSource: AuthDataSource,
|
private val accountManagerInteractor: AccountManagerInteractor,
|
||||||
private val storage: AuthStorage,
|
|
||||||
) : AuthRepo {
|
) : AuthRepo {
|
||||||
|
|
||||||
override val isAuthorizedFlow: Flow<Boolean>
|
override val isAuthorizedFlow: Flow<Boolean>
|
||||||
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) {
|
override suspend fun authenticate(username: String, password: String) {
|
||||||
Timber.v("authenticate() called with: username = $username, password = $password")
|
Timber.v("authenticate() called with: username = $username, password = $password")
|
||||||
val accessToken = dataSource.authenticate(username, password)
|
val account = accountManagerInteractor.addAccount(username, password)
|
||||||
Timber.d("authenticate result is \"$accessToken\"")
|
runCatchingExceptCancel {
|
||||||
storage.storeAuthData(AUTH_HEADER_FORMAT.format(accessToken))
|
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 =
|
override suspend fun requireAuthHeader(): String =
|
||||||
checkNotNull(getAuthHeader()) { "Auth header is null when it was required" }
|
checkNotNull(getAuthHeader()) { "Auth header is null when it was required" }
|
||||||
|
|
||||||
override suspend fun logout() {
|
override suspend fun logout() {
|
||||||
Timber.v("logout() called")
|
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 {
|
companion object {
|
||||||
|
|||||||
@@ -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<String>
|
|
||||||
get() = preferencesStorage.authHeaderKey
|
|
||||||
override val authHeaderFlow: Flow<String?>
|
|
||||||
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<String?> {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,25 @@
|
|||||||
package gq.kirmanak.mealient.di
|
package gq.kirmanak.mealient.di
|
||||||
|
|
||||||
|
import android.accounts.AccountManager
|
||||||
|
import android.content.Context
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import gq.kirmanak.mealient.R
|
import gq.kirmanak.mealient.R
|
||||||
import gq.kirmanak.mealient.data.auth.AuthDataSource
|
import gq.kirmanak.mealient.data.auth.AuthDataSource
|
||||||
import gq.kirmanak.mealient.data.auth.AuthRepo
|
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.AuthDataSourceImpl
|
||||||
import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl
|
import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl
|
||||||
import gq.kirmanak.mealient.data.auth.impl.AuthService
|
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.baseurl.BaseURLStorage
|
||||||
import gq.kirmanak.mealient.data.network.RetrofitBuilder
|
import gq.kirmanak.mealient.data.network.RetrofitBuilder
|
||||||
import gq.kirmanak.mealient.data.network.ServiceFactory
|
import gq.kirmanak.mealient.data.network.ServiceFactory
|
||||||
import gq.kirmanak.mealient.data.network.createServiceFactory
|
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 gq.kirmanak.mealient.service.auth.AccountParameters
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -26,8 +29,6 @@ interface AuthModule {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val ACCOUNT_TYPE = "Mealient"
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideAuthServiceFactory(retrofitBuilder: RetrofitBuilder,
|
fun provideAuthServiceFactory(retrofitBuilder: RetrofitBuilder,
|
||||||
@@ -54,9 +55,11 @@ interface AuthModule {
|
|||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage
|
fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo
|
fun bindAccountManagerInteractor(
|
||||||
|
accountManagerInteractorImpl: AccountManagerInteractorImpl
|
||||||
|
): AccountManagerInteractor
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ package gq.kirmanak.mealient.service.auth
|
|||||||
|
|
||||||
import android.accounts.*
|
import android.accounts.*
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import gq.kirmanak.mealient.data.auth.AuthDataSource
|
import gq.kirmanak.mealient.data.auth.AuthDataSource
|
||||||
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError
|
import gq.kirmanak.mealient.data.network.NetworkError
|
||||||
import gq.kirmanak.mealient.ui.auth.AuthenticatorActivity
|
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
|
||||||
|
import gq.kirmanak.mealient.service.auth.AuthenticatorException.*
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -21,55 +21,30 @@ class AccountAuthenticatorImpl @Inject constructor(
|
|||||||
private val accountManager: AccountManager,
|
private val accountManager: AccountManager,
|
||||||
) : AbstractAccountAuthenticator(context) {
|
) : AbstractAccountAuthenticator(context) {
|
||||||
|
|
||||||
override fun addAccount(
|
|
||||||
response: AccountAuthenticatorResponse,
|
|
||||||
accountType: String,
|
|
||||||
authTokenType: String,
|
|
||||||
requiredFeatures: Array<out String>?,
|
|
||||||
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(
|
override fun getAuthToken(
|
||||||
response: AccountAuthenticatorResponse,
|
response: AccountAuthenticatorResponse,
|
||||||
account: Account,
|
account: Account,
|
||||||
authTokenType: String,
|
authTokenType: String,
|
||||||
options: Bundle
|
options: Bundle?
|
||||||
): Bundle {
|
): Bundle {
|
||||||
Timber.v("getAuthToken() called with: response = $response, account = $account, authTokenType = $authTokenType, options = $options")
|
Timber.v("getAuthToken() called with: response = $response, account = $account, authTokenType = $authTokenType, options = $options")
|
||||||
|
|
||||||
val password: String?
|
val password = try {
|
||||||
val baseUrl: String?
|
|
||||||
try {
|
|
||||||
checkAccountType(account.type)
|
checkAccountType(account.type)
|
||||||
checkAuthTokenType(authTokenType)
|
checkAuthTokenType(authTokenType)
|
||||||
password = accountManager.getPassword(account)
|
accountManager.getPassword(account) ?: throw AccountNotFound(account)
|
||||||
?: throw AuthenticatorException.AccountNotFound(account)
|
|
||||||
baseUrl = options.getString(KEY_BASE_URL) ?: throw AuthenticatorException.NoBaseUrl
|
|
||||||
} catch (e: AuthenticatorException) {
|
} catch (e: AuthenticatorException) {
|
||||||
Timber.e(e, "getAuthToken: validation failure")
|
Timber.e(e, "getAuthToken: validation failure")
|
||||||
return e.bundle
|
return e.bundle
|
||||||
}
|
}
|
||||||
|
|
||||||
val token = try {
|
val token = runCatchingExceptCancel {
|
||||||
runBlocking { authDataSource.authenticate(account.name, password, baseUrl) }
|
runBlocking { authDataSource.authenticate(account.name, password) }
|
||||||
} catch (e: RuntimeException) {
|
}.getOrElse {
|
||||||
return when (e) {
|
return when (it) {
|
||||||
is AuthenticationError.NotMealie -> AuthenticatorException.NotMealie.bundle
|
is NetworkError.NotMealie -> NotMealie.bundle
|
||||||
is AuthenticationError.Unauthorized -> AuthenticatorException.Unauthorized.bundle
|
is NetworkError.Unauthorized -> Unauthorized.bundle
|
||||||
else -> throw NetworkErrorException(e)
|
else -> throw NetworkErrorException(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +62,18 @@ class AccountAuthenticatorImpl @Inject constructor(
|
|||||||
options: Bundle?
|
options: Bundle?
|
||||||
): Bundle {
|
): Bundle {
|
||||||
Timber.v("confirmCredentials() called with: response = $response, account = $account, options = $options")
|
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<out String>?,
|
||||||
|
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(
|
override fun editProperties(
|
||||||
@@ -114,7 +100,7 @@ class AccountAuthenticatorImpl @Inject constructor(
|
|||||||
options: Bundle?
|
options: Bundle?
|
||||||
): Bundle {
|
): Bundle {
|
||||||
Timber.v("updateCredentials() called with: response = $response, account = $account, authTokenType = $authTokenType, options = $options")
|
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(
|
override fun hasFeatures(
|
||||||
@@ -129,13 +115,13 @@ class AccountAuthenticatorImpl @Inject constructor(
|
|||||||
|
|
||||||
private fun checkAccountType(accountType: String) {
|
private fun checkAccountType(accountType: String) {
|
||||||
if (accountType != accountParameters.accountType) {
|
if (accountType != accountParameters.accountType) {
|
||||||
throw AuthenticatorException.UnsupportedAccountType(accountType)
|
throw UnsupportedAccountType(accountType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkAuthTokenType(authTokenType: String) {
|
private fun checkAuthTokenType(authTokenType: String) {
|
||||||
if (authTokenType != accountParameters.accountType) {
|
if (authTokenType != accountParameters.authTokenType) {
|
||||||
throw AuthenticatorException.UnsupportedAccountType(authTokenType)
|
throw UnsupportedAuthTokenType(authTokenType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ interface AccountManagerInteractor {
|
|||||||
|
|
||||||
fun getAccounts(): Array<Account>
|
fun getAccounts(): Array<Account>
|
||||||
|
|
||||||
suspend fun addAccount(): Account
|
suspend fun addAccount(email: String, password: String): Account
|
||||||
|
|
||||||
suspend fun getAuthToken(account: Account): String
|
suspend fun getAuthToken(account: Account): String
|
||||||
|
|
||||||
fun accountUpdatesFlow(): Flow<Array<Account>>
|
fun accountUpdatesFlow(): Flow<Array<Account>>
|
||||||
|
|
||||||
|
suspend fun removeAccount(account: Account)
|
||||||
}
|
}
|
||||||
@@ -2,38 +2,30 @@ package gq.kirmanak.mealient.service.auth
|
|||||||
|
|
||||||
import android.accounts.Account
|
import android.accounts.Account
|
||||||
import android.accounts.AccountManager
|
import android.accounts.AccountManager
|
||||||
import android.app.Activity
|
|
||||||
import dagger.hilt.android.scopes.ActivityScoped
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@ActivityScoped
|
@Singleton
|
||||||
class AccountManagerInteractorImpl @Inject constructor(
|
class AccountManagerInteractorImpl @Inject constructor(
|
||||||
private val accountManager: AccountManager,
|
private val accountManager: AccountManager,
|
||||||
private val accountParameters: AccountParameters,
|
private val accountParameters: AccountParameters,
|
||||||
private val activity: Activity
|
|
||||||
) : AccountManagerInteractor {
|
) : AccountManagerInteractor {
|
||||||
|
|
||||||
override fun getAccounts(): Array<Account> {
|
override fun getAccounts(): Array<Account> {
|
||||||
Timber.v("getAccounts() called")
|
Timber.v("getAccounts() called")
|
||||||
val accounts = accountManager.getAccountsByType(accountParameters.accountType)
|
val accounts = accountManager.getAccountsByType(accountParameters.accountType)
|
||||||
Timber.v("getAccounts() returned: $accounts")
|
Timber.v("getAccounts() returned: ${accounts.contentToString()}")
|
||||||
return accounts
|
return accounts
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun addAccount(): Account {
|
override suspend fun addAccount(email: String, password: String): Account {
|
||||||
Timber.v("addAccount() called")
|
Timber.v("addAccount() called with: email = $email, password = $password")
|
||||||
val bundle = accountManager.addAccount(
|
val account = Account(email, accountParameters.accountType)
|
||||||
accountParameters.accountType,
|
removeAccount(account) // Remove account if it was created earlier
|
||||||
accountParameters.authTokenType,
|
accountManager.addAccountExplicitly(account, password, null)
|
||||||
null,
|
return account
|
||||||
null,
|
|
||||||
activity,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
).await()
|
|
||||||
return bundle.toAccount()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getAuthToken(account: Account): String {
|
override suspend fun getAuthToken(account: Account): String {
|
||||||
@@ -42,18 +34,27 @@ class AccountManagerInteractorImpl @Inject constructor(
|
|||||||
account,
|
account,
|
||||||
accountParameters.authTokenType,
|
accountParameters.authTokenType,
|
||||||
null,
|
null,
|
||||||
activity,
|
null,
|
||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
).await()
|
).await()
|
||||||
val receivedAccount = bundle.toAccount()
|
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()
|
val token = bundle.authToken()
|
||||||
Timber.v("getAuthToken() returned: $token")
|
Timber.v("getAuthToken() returned: $token")
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun accountUpdatesFlow(): Flow<Array<Account>> {
|
override fun accountUpdatesFlow(): Flow<Array<Account>> {
|
||||||
|
Timber.v("accountUpdatesFlow() called")
|
||||||
return accountManager.accountUpdatesFlow(accountParameters.accountType)
|
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()}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,19 +7,16 @@ import android.accounts.AccountManagerFuture
|
|||||||
import android.accounts.OnAccountsUpdateListener
|
import android.accounts.OnAccountsUpdateListener
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import gq.kirmanak.mealient.extensions.logErrors
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.channels.onFailure
|
|
||||||
import kotlinx.coroutines.channels.onSuccess
|
|
||||||
import kotlinx.coroutines.channels.trySendBlocking
|
import kotlinx.coroutines.channels.trySendBlocking
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
internal const val KEY_BASE_URL = "mealientBaseUrl"
|
|
||||||
|
|
||||||
internal suspend fun <T> AccountManagerFuture<T>.await(): T = withContext(Dispatchers.IO) { result }
|
internal suspend fun <T> AccountManagerFuture<T>.await(): T = withContext(Dispatchers.IO) { result }
|
||||||
|
|
||||||
internal fun Bundle.toAccount(): Account = Account(accountName(), accountType())
|
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.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)
|
private fun Bundle.string(key: String, error: () -> String) = checkNotNull(getString(key), error)
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@@ -37,12 +36,10 @@ internal fun AccountManager.accountUpdatesFlow(vararg types: String): Flow<Array
|
|||||||
callbackFlow {
|
callbackFlow {
|
||||||
Timber.v("accountUpdatesFlow() called")
|
Timber.v("accountUpdatesFlow() called")
|
||||||
val listener = OnAccountsUpdateListener { accounts ->
|
val listener = OnAccountsUpdateListener { accounts ->
|
||||||
Timber.d("accountUpdatesFlow: updated accounts = $accounts")
|
Timber.d("accountUpdatesFlow: updated accounts = ${accounts.contentToString()}")
|
||||||
val filtered = accounts.filter { types.contains(it.type) }.toTypedArray()
|
val filtered = accounts.filter { types.contains(it.type) }.toTypedArray()
|
||||||
Timber.d("accountUpdatesFlow: filtered accounts = $filtered")
|
Timber.d("accountUpdatesFlow: filtered accounts = ${filtered.contentToString()}")
|
||||||
trySendBlocking(filtered)
|
trySendBlocking(filtered).logErrors("accountUpdatesFlow")
|
||||||
.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) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
addOnAccountsUpdatedListener(listener, null, true, types)
|
addOnAccountsUpdatedListener(listener, null, true, types)
|
||||||
|
|||||||
@@ -35,18 +35,15 @@ sealed class AuthenticatorException(
|
|||||||
errorMessage = "$account not found"
|
errorMessage = "$account not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
object NoBaseUrl : AuthenticatorException(
|
|
||||||
errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS,
|
|
||||||
errorMessage = "Base URL was not provided"
|
|
||||||
)
|
|
||||||
|
|
||||||
object Unauthorized : AuthenticatorException(
|
object Unauthorized : AuthenticatorException(
|
||||||
errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS,
|
errorCode = ErrorCode.Unauthorized.ordinal,
|
||||||
errorMessage = "E-mail or password weren't correct"
|
errorMessage = "E-mail or password weren't correct"
|
||||||
)
|
)
|
||||||
|
|
||||||
object NotMealie : AuthenticatorException(
|
object NotMealie : AuthenticatorException(
|
||||||
errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS,
|
errorCode = ErrorCode.NotMealie.ordinal,
|
||||||
errorMessage = "Base URL must be pointing at a non Mealie server"
|
errorMessage = "Base URL must be pointing at a non Mealie server"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class ErrorCode { NotMealie, Unauthorized; }
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import gq.kirmanak.mealient.data.auth.AuthRepo
|
import gq.kirmanak.mealient.data.auth.AuthRepo
|
||||||
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
|
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@@ -32,18 +31,14 @@ class AuthenticationViewModel @Inject constructor(
|
|||||||
var authRequested: Boolean by authRequestsFlow::value
|
var authRequested: Boolean by authRequestsFlow::value
|
||||||
var showLoginButton: Boolean by showLoginButtonFlow::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 {
|
suspend fun authenticate(username: String, password: String) = runCatchingExceptCancel {
|
||||||
authRepo.authenticate(username, password)
|
authRepo.authenticate(username, password)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
Timber.e(it, "authenticate: can't authenticate")
|
Timber.e(it, "authenticate: can't authenticate")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
Timber.v("logout() called")
|
||||||
|
viewModelScope.launch { authRepo.logout() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
|
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:accountType="@string/account_type"
|
android:accountType="@string/account_type"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name" />
|
android:label="@string/app_name" />
|
||||||
@@ -1,78 +1,22 @@
|
|||||||
package gq.kirmanak.mealient.data.auth.impl
|
package gq.kirmanak.mealient.data.auth.impl
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import gq.kirmanak.mealient.service.auth.AccountManagerInteractor
|
||||||
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 io.mockk.MockKAnnotations
|
import io.mockk.MockKAnnotations
|
||||||
import io.mockk.coEvery
|
|
||||||
import io.mockk.coVerify
|
|
||||||
import io.mockk.impl.annotations.MockK
|
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.Before
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
class AuthRepoImplTest {
|
||||||
class AuthRepoImplTest : RobolectricTest() {
|
|
||||||
|
|
||||||
@MockK
|
@MockK
|
||||||
lateinit var dataSource: AuthDataSource
|
lateinit var accountManagerInteractor: AccountManagerInteractor
|
||||||
|
|
||||||
@MockK(relaxUnitFun = true)
|
|
||||||
lateinit var storage: AuthStorage
|
|
||||||
|
|
||||||
lateinit var subject: AuthRepoImpl
|
lateinit var subject: AuthRepoImpl
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
MockKAnnotations.init(this)
|
MockKAnnotations.init(this)
|
||||||
subject = AuthRepoImpl(dataSource, storage)
|
subject = AuthRepoImpl(accountManagerInteractor)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
// TODO write the actual tests
|
||||||
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() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user