Use AccountManager

This commit is contained in:
Kirill Kamakin
2022-04-03 16:11:24 +05:00
parent 608aec525b
commit 096b5389bd
15 changed files with 475 additions and 20 deletions

View File

@@ -15,16 +15,27 @@
android:roundIcon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true" android:supportsRtl="true"
tools:ignore="UnusedAttribute" tools:ignore="UnusedAttribute"
android:theme="@style/Theme.Mealient"> android:theme="@style/Theme.Mealient">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
</application>
<service
android:name=".service.auth.AuthenticationService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/account_authenticator" />
</service>
</application>
</manifest> </manifest>

View File

@@ -1,15 +1,18 @@
package gq.kirmanak.mealient.data.auth.impl package gq.kirmanak.mealient.data.auth.impl
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import dagger.hilt.android.scopes.ActivityScoped
import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.storage.PreferencesStorage import gq.kirmanak.mealient.data.storage.PreferencesStorage
import gq.kirmanak.mealient.service.auth.AccountManagerInteractor
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Singleton @ActivityScoped
class AuthStorageImpl @Inject constructor( class AuthStorageImpl @Inject constructor(
private val accountManagerInteractorImpl: AccountManagerInteractor,
private val preferencesStorage: PreferencesStorage, private val preferencesStorage: PreferencesStorage,
) : AuthStorage { ) : AuthStorage {
@@ -30,6 +33,18 @@ class AuthStorageImpl @Inject constructor(
return 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() { override suspend fun clearAuthData() {
Timber.v("clearAuthData() called") Timber.v("clearAuthData() called")
preferencesStorage.removeValues(authHeaderKey) preferencesStorage.removeValues(authHeaderKey)

View File

@@ -5,6 +5,7 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
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.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.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.AccountParameters
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -24,12 +26,26 @@ interface AuthModule {
companion object { companion object {
const val ACCOUNT_TYPE = "Mealient"
@Provides @Provides
@Singleton @Singleton
fun provideAuthServiceFactory( fun provideAuthServiceFactory(retrofitBuilder: RetrofitBuilder,
retrofitBuilder: RetrofitBuilder,
baseURLStorage: BaseURLStorage, baseURLStorage: BaseURLStorage,
): ServiceFactory<AuthService> = retrofitBuilder.createServiceFactory(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 @Binds

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
package gq.kirmanak.mealient.service.auth
data class AccountParameters(
val accountType: String,
val authTokenType: String,
)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
package gq.kirmanak.mealient.ui.auth
import androidx.appcompat.app.AppCompatActivity
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class AuthenticatorActivity : AppCompatActivity() {
}

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.auth.AuthenticatorActivity">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_holder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:liftOnScroll="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
style="@style/Widget.MaterialComponents.Toolbar.Primary"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
app:layout_scrollFlags="scroll|snap" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar_holder"
app:navGraph="@navigation/authenticator_graph" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/authenticator_graph"
app:startDestination="@id/authenticationFragment2">
<fragment
android:id="@+id/authenticationFragment2"
android:name="gq.kirmanak.mealient.ui.auth.AuthenticationFragment"
android:label="fragment_authentication"
tools:layout="@layout/fragment_authentication" />
</navigation>

View File

@@ -12,18 +12,20 @@
<string name="fragment_recipe_info_ingredients_header">Ingredients</string> <string name="fragment_recipe_info_ingredients_header">Ingredients</string>
<string name="fragment_recipe_info_instructions_header">Instructions</string> <string name="fragment_recipe_info_instructions_header">Instructions</string>
<string name="fragment_disclaimer_main_text">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.</string> <string name="fragment_disclaimer_main_text">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.</string>
<string name="fragment_disclaimer_button_okay">Okay</string>
<string name="view_holder_recipe_instructions_step">Step: %d</string>
<string name="fragment_authentication_email_input_empty">E-mail can\'t be empty</string>
<string name="fragment_authentication_password_input_empty">Password can\'t be empty</string>
<string name="fragment_baseurl_url_input_empty">URL can\'t be empty</string> <string name="fragment_baseurl_url_input_empty">URL can\'t be empty</string>
<string name="fragment_authentication_credentials_incorrect">E-mail or password is incorrect.</string>
<string name="fragment_base_url_no_connection">Can\'t connect, check address.</string> <string name="fragment_base_url_no_connection">Can\'t connect, check address.</string>
<string name="fragment_base_url_unexpected_response">Unexpected response. Is it Mealie?</string> <string name="fragment_base_url_unexpected_response">Unexpected response. Is it Mealie?</string>
<string name="fragment_authentication_unknown_error">Something went wrong, please try again.</string>
<string name="fragment_base_url_malformed_url">Check URL format: %s</string> <string name="fragment_base_url_malformed_url">Check URL format: %s</string>
<string name="fragment_base_url_save">Proceed</string> <string name="fragment_base_url_save">Proceed</string>
<string name="fragment_base_url_unknown_error" translatable="false">@string/fragment_authentication_unknown_error</string> <string name="fragment_base_url_unknown_error" translatable="false">@string/fragment_authentication_unknown_error</string>
<string name="menu_main_toolbar_content_description_login" translatable="false">@string/menu_main_toolbar_login</string> <string name="menu_main_toolbar_content_description_login" translatable="false">@string/menu_main_toolbar_login</string>
<string name="menu_main_toolbar_login">Login</string> <string name="menu_main_toolbar_login">Login</string>
<string name="fragment_disclaimer_button_okay">Okay</string>
<string name="view_holder_recipe_instructions_step">Step: %d</string>
<string name="fragment_authentication_email_input_empty">E-mail can\'t be empty</string>
<string name="fragment_authentication_password_input_empty">Password can\'t be empty</string>
<string name="fragment_authentication_credentials_incorrect">E-mail or password is incorrect.</string>
<string name="fragment_authentication_unknown_error">Something went wrong, please try again.</string>
<string name="account_type" translatable="false">Mealient</string>
<string name="auth_token_type" translatable="false">mealientAuthToken</string>
</resources> </resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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:icon="@mipmap/ic_launcher"
android:label="@string/app_name" />