Use AccountManager
This commit is contained in:
@@ -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<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)
|
||||
|
||||
@@ -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<AuthService> = 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
|
||||
|
||||
@@ -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<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(
|
||||
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<out String>?
|
||||
): 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package gq.kirmanak.mealient.service.auth
|
||||
|
||||
import android.accounts.Account
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AccountManagerInteractor {
|
||||
|
||||
fun getAccounts(): Array<Account>
|
||||
|
||||
suspend fun addAccount(): Account
|
||||
|
||||
suspend fun getAuthToken(account: Account): String
|
||||
|
||||
fun accountUpdatesFlow(): Flow<Array<Account>>
|
||||
}
|
||||
@@ -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<Account> {
|
||||
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<Array<Account>> {
|
||||
return accountManager.accountUpdatesFlow(accountParameters.accountType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package gq.kirmanak.mealient.service.auth
|
||||
|
||||
data class AccountParameters(
|
||||
val accountType: String,
|
||||
val authTokenType: String,
|
||||
)
|
||||
@@ -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 <T> AccountManagerFuture<T>.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<Array<Account>> =
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package gq.kirmanak.mealient.ui.auth
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AuthenticatorActivity : AppCompatActivity() {
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user