Implement the simplest account manager authentication

This commit is contained in:
Kirill Kamakin
2022-04-05 16:51:53 +05:00
parent 096b5389bd
commit 57f4ec4e22
13 changed files with 124 additions and 233 deletions

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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
} }

View File

@@ -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)
} }
} }
} }

View File

@@ -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)
} }

View File

@@ -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()}")
}
} }

View File

@@ -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)

View File

@@ -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; }
} }

View File

@@ -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() }
}
} }

View File

@@ -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" />

View File

@@ -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() }
}
} }