diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 26f6c08..49dab32 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -15,16 +15,27 @@
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
tools:ignore="UnusedAttribute"
- android:theme="@style/Theme.Mealient">
-
-
-
+ android:theme="@style/Theme.Mealient">
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt
index 94243d4..a15ac30 100644
--- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt
+++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt
@@ -1,15 +1,18 @@
package gq.kirmanak.mealient.data.auth.impl
import androidx.datastore.preferences.core.Preferences
+import dagger.hilt.android.scopes.ActivityScoped
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.storage.PreferencesStorage
+import gq.kirmanak.mealient.service.auth.AccountManagerInteractor
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
import timber.log.Timber
import javax.inject.Inject
-import javax.inject.Singleton
-@Singleton
+@ActivityScoped
class AuthStorageImpl @Inject constructor(
+ private val accountManagerInteractorImpl: AccountManagerInteractor,
private val preferencesStorage: PreferencesStorage,
) : AuthStorage {
@@ -30,6 +33,18 @@ class AuthStorageImpl @Inject constructor(
return token
}
+ override fun authHeaderObservable(): Flow {
+ Timber.v("authHeaderObservable() called")
+ return accountManagerInteractorImpl.accountUpdatesFlow()
+ .map { it.firstOrNull() }
+ .map { account ->
+ account ?: return@map null
+ runCatching { accountManagerInteractorImpl.getAuthToken(account) }
+ .onFailure { Timber.e(it, "authHeaderObservable: can't get token") }
+ .getOrNull()
+ }
+ }
+
override suspend fun clearAuthData() {
Timber.v("clearAuthData() called")
preferencesStorage.removeValues(authHeaderKey)
diff --git a/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt
index 18e247a..66f556d 100644
--- a/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt
+++ b/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt
@@ -5,6 +5,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
+import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
@@ -16,6 +17,7 @@ import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
+import gq.kirmanak.mealient.service.auth.AccountParameters
import javax.inject.Singleton
@Module
@@ -24,12 +26,26 @@ interface AuthModule {
companion object {
+ const val ACCOUNT_TYPE = "Mealient"
+
@Provides
@Singleton
- fun provideAuthServiceFactory(
- retrofitBuilder: RetrofitBuilder,
+ fun provideAuthServiceFactory(retrofitBuilder: RetrofitBuilder,
baseURLStorage: BaseURLStorage,
): ServiceFactory = retrofitBuilder.createServiceFactory(baseURLStorage)
+
+ @Provides
+ @Singleton
+ fun provideAccountManager(@ApplicationContext context: Context): AccountManager {
+ return AccountManager.get(context)
+ }
+
+ @Provides
+ @Singleton
+ fun provideAccountType(@ApplicationContext context: Context) = AccountParameters(
+ accountType = context.getString(R.string.account_type),
+ authTokenType = context.getString(R.string.auth_token_type),
+ )
}
@Binds
diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt
new file mode 100644
index 0000000..bde58f0
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt
@@ -0,0 +1,141 @@
+package gq.kirmanak.mealient.service.auth
+
+import android.accounts.*
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import dagger.hilt.android.qualifiers.ApplicationContext
+import gq.kirmanak.mealient.data.auth.AuthDataSource
+import gq.kirmanak.mealient.data.auth.impl.AuthenticationError
+import gq.kirmanak.mealient.ui.auth.AuthenticatorActivity
+import kotlinx.coroutines.runBlocking
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AccountAuthenticatorImpl @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val authDataSource: AuthDataSource,
+ private val accountParameters: AccountParameters,
+ private val accountManager: AccountManager,
+) : AbstractAccountAuthenticator(context) {
+
+ override fun addAccount(
+ response: AccountAuthenticatorResponse,
+ accountType: String,
+ authTokenType: String,
+ requiredFeatures: Array?,
+ options: Bundle,
+ ): Bundle {
+ Timber.v("addAccount() called with: response = $response, accountType = $accountType, authTokenType = $authTokenType, requiredFeatures = $requiredFeatures, options = $options")
+
+ try {
+ checkAccountType(accountType)
+ checkAuthTokenType(authTokenType)
+ } catch (e: AuthenticatorException) {
+ Timber.e(e, "addAccount: validation failure")
+ return e.bundle
+ }
+
+ val intent = Intent(context, AuthenticatorActivity::class.java)
+ return Bundle().apply { putParcelable(AccountManager.KEY_INTENT, intent) }
+ }
+
+ override fun getAuthToken(
+ response: AccountAuthenticatorResponse,
+ account: Account,
+ authTokenType: String,
+ options: Bundle
+ ): Bundle {
+ Timber.v("getAuthToken() called with: response = $response, account = $account, authTokenType = $authTokenType, options = $options")
+
+ val password: String?
+ val baseUrl: String?
+ try {
+ checkAccountType(account.type)
+ checkAuthTokenType(authTokenType)
+ password = accountManager.getPassword(account)
+ ?: throw AuthenticatorException.AccountNotFound(account)
+ baseUrl = options.getString(KEY_BASE_URL) ?: throw AuthenticatorException.NoBaseUrl
+ } catch (e: AuthenticatorException) {
+ Timber.e(e, "getAuthToken: validation failure")
+ return e.bundle
+ }
+
+ val token = try {
+ runBlocking { authDataSource.authenticate(account.name, password, baseUrl) }
+ } catch (e: RuntimeException) {
+ return when (e) {
+ is AuthenticationError.NotMealie -> AuthenticatorException.NotMealie.bundle
+ is AuthenticationError.Unauthorized -> AuthenticatorException.Unauthorized.bundle
+ else -> throw NetworkErrorException(e)
+ }
+ }
+
+ return Bundle().apply {
+ putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
+ putString(AccountManager.KEY_ACCOUNT_TYPE, accountParameters.accountType)
+ putString(AccountManager.KEY_AUTHTOKEN, token)
+ }
+ }
+
+ // region Unsupported operations
+ override fun confirmCredentials(
+ response: AccountAuthenticatorResponse?,
+ account: Account?,
+ options: Bundle?
+ ): Bundle {
+ Timber.v("confirmCredentials() called with: response = $response, account = $account, options = $options")
+ return AuthenticatorException.UnsupportedOperation("confirmCredentials").bundle
+ }
+
+ override fun editProperties(
+ response: AccountAuthenticatorResponse,
+ accountType: String,
+ ): Bundle? {
+ Timber.v("editProperties() called with: response = $response, accountType = $accountType")
+ response.onError(
+ AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION,
+ "editProperties is not supported"
+ )
+ return null
+ }
+
+ override fun getAuthTokenLabel(authTokenType: String?): String? {
+ Timber.v("getAuthTokenLabel() called with: authTokenType = $authTokenType")
+ return null
+ }
+
+ override fun updateCredentials(
+ response: AccountAuthenticatorResponse?,
+ account: Account?,
+ authTokenType: String?,
+ options: Bundle?
+ ): Bundle {
+ Timber.v("updateCredentials() called with: response = $response, account = $account, authTokenType = $authTokenType, options = $options")
+ return AuthenticatorException.UnsupportedOperation("updateCredentials").bundle
+ }
+
+ override fun hasFeatures(
+ response: AccountAuthenticatorResponse?,
+ account: Account?,
+ features: Array?
+ ): Bundle {
+ Timber.v("hasFeatures() called with: response = $response, account = $account, features = $features")
+ return Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, true) }
+ }
+ // end region
+
+ private fun checkAccountType(accountType: String) {
+ if (accountType != accountParameters.accountType) {
+ throw AuthenticatorException.UnsupportedAccountType(accountType)
+ }
+ }
+
+ private fun checkAuthTokenType(authTokenType: String) {
+ if (authTokenType != accountParameters.accountType) {
+ throw AuthenticatorException.UnsupportedAccountType(authTokenType)
+ }
+ }
+}
diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt
new file mode 100644
index 0000000..8bfaa37
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt
@@ -0,0 +1,15 @@
+package gq.kirmanak.mealient.service.auth
+
+import android.accounts.Account
+import kotlinx.coroutines.flow.Flow
+
+interface AccountManagerInteractor {
+
+ fun getAccounts(): Array
+
+ suspend fun addAccount(): Account
+
+ suspend fun getAuthToken(account: Account): String
+
+ fun accountUpdatesFlow(): Flow>
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt
new file mode 100644
index 0000000..03d92e1
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt
@@ -0,0 +1,59 @@
+package gq.kirmanak.mealient.service.auth
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.app.Activity
+import dagger.hilt.android.scopes.ActivityScoped
+import kotlinx.coroutines.flow.Flow
+import timber.log.Timber
+import javax.inject.Inject
+
+@ActivityScoped
+class AccountManagerInteractorImpl @Inject constructor(
+ private val accountManager: AccountManager,
+ private val accountParameters: AccountParameters,
+ private val activity: Activity
+) : AccountManagerInteractor {
+
+ override fun getAccounts(): Array {
+ Timber.v("getAccounts() called")
+ val accounts = accountManager.getAccountsByType(accountParameters.accountType)
+ Timber.v("getAccounts() returned: $accounts")
+ return accounts
+ }
+
+ override suspend fun addAccount(): Account {
+ Timber.v("addAccount() called")
+ val bundle = accountManager.addAccount(
+ accountParameters.accountType,
+ accountParameters.authTokenType,
+ null,
+ null,
+ activity,
+ null,
+ null
+ ).await()
+ return bundle.toAccount()
+ }
+
+ override suspend fun getAuthToken(account: Account): String {
+ Timber.v("getAuthToken() called with: account = $account")
+ val bundle = accountManager.getAuthToken(
+ account,
+ accountParameters.authTokenType,
+ null,
+ activity,
+ null,
+ null
+ ).await()
+ val receivedAccount = bundle.toAccount()
+ check(account == receivedAccount) { "Received account ($receivedAccount) differs from requested ($account)" }
+ val token = bundle.authToken()
+ Timber.v("getAuthToken() returned: $token")
+ return token
+ }
+
+ override fun accountUpdatesFlow(): Flow> {
+ return accountManager.accountUpdatesFlow(accountParameters.accountType)
+ }
+}
diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountParameters.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountParameters.kt
new file mode 100644
index 0000000..9a6450c
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountParameters.kt
@@ -0,0 +1,6 @@
+package gq.kirmanak.mealient.service.auth
+
+data class AccountParameters(
+ val accountType: String,
+ val authTokenType: String,
+)
diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt
new file mode 100644
index 0000000..f14fb3e
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt
@@ -0,0 +1,56 @@
+package gq.kirmanak.mealient.service.auth
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.accounts.AccountManager.*
+import android.accounts.AccountManagerFuture
+import android.accounts.OnAccountsUpdateListener
+import android.os.Build
+import android.os.Bundle
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.channels.onFailure
+import kotlinx.coroutines.channels.onSuccess
+import kotlinx.coroutines.channels.trySendBlocking
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+
+internal const val KEY_BASE_URL = "mealientBaseUrl"
+
+internal suspend fun AccountManagerFuture.await(): T = withContext(Dispatchers.IO) { result }
+
+internal fun Bundle.toAccount(): Account = Account(accountName(), accountType())
+
+internal fun Bundle.accountType(): String = string(KEY_ACCOUNT_TYPE) { "Account type is null" }
+
+internal fun Bundle.accountName(): String = string(KEY_ACCOUNT_NAME) { "Account name is null" }
+
+internal fun Bundle.authToken(): String = string(KEY_AUTHTOKEN) { "Auth token is null" }
+
+private fun Bundle.string(key: String, error: () -> String) = checkNotNull(getString(key), error)
+
+@OptIn(ExperimentalCoroutinesApi::class)
+internal fun AccountManager.accountUpdatesFlow(vararg types: String): Flow> =
+ callbackFlow {
+ Timber.v("accountUpdatesFlow() called")
+ val listener = OnAccountsUpdateListener { accounts ->
+ Timber.d("accountUpdatesFlow: updated accounts = $accounts")
+ val filtered = accounts.filter { types.contains(it.type) }.toTypedArray()
+ Timber.d("accountUpdatesFlow: filtered accounts = $filtered")
+ trySendBlocking(filtered)
+ .onSuccess { Timber.d("accountUpdatesFlow: sent accounts update") }
+ .onFailure { Timber.e(it, "accountUpdatesFlow: failed to send update") }
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ addOnAccountsUpdatedListener(listener, null, true, types)
+ } else {
+ addOnAccountsUpdatedListener(listener, null, true)
+ }
+ awaitClose {
+ Timber.d("accountUpdatesFlow: cancelled")
+ removeOnAccountsUpdatedListener(listener)
+ }
+ }
diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticationService.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticationService.kt
new file mode 100644
index 0000000..e59017d
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticationService.kt
@@ -0,0 +1,16 @@
+package gq.kirmanak.mealient.service.auth
+
+import android.app.Service
+import android.content.Intent
+import android.os.IBinder
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class AuthenticationService : Service() {
+
+ @Inject
+ lateinit var accountAuthenticatorImpl: AccountAuthenticatorImpl
+
+ override fun onBind(intent: Intent?): IBinder? = accountAuthenticatorImpl.iBinder
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt
new file mode 100644
index 0000000..3b06440
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt
@@ -0,0 +1,52 @@
+package gq.kirmanak.mealient.service.auth
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.os.Bundle
+
+sealed class AuthenticatorException(
+ val bundle: Bundle
+) : RuntimeException() {
+
+ constructor(errorCode: Int, errorMessage: String) : this(
+ Bundle().apply {
+ putInt(AccountManager.KEY_ERROR_CODE, errorCode)
+ putString(AccountManager.KEY_ERROR_MESSAGE, errorMessage)
+ }
+ )
+
+ class UnsupportedAuthTokenType(received: String?) : AuthenticatorException(
+ errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS,
+ errorMessage = "Received auth token type = $received"
+ )
+
+ class UnsupportedAccountType(received: String?) : AuthenticatorException(
+ errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS,
+ errorMessage = "Received account type = $received"
+ )
+
+ class UnsupportedOperation(operation: String) : AuthenticatorException(
+ errorCode = AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION,
+ errorMessage = "$operation is not supported"
+ )
+
+ class AccountNotFound(account: Account) : AuthenticatorException(
+ errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS,
+ errorMessage = "$account not found"
+ )
+
+ object NoBaseUrl : AuthenticatorException(
+ errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS,
+ errorMessage = "Base URL was not provided"
+ )
+
+ object Unauthorized : AuthenticatorException(
+ errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS,
+ errorMessage = "E-mail or password weren't correct"
+ )
+
+ object NotMealie : AuthenticatorException(
+ errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS,
+ errorMessage = "Base URL must be pointing at a non Mealie server"
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticatorActivity.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticatorActivity.kt
new file mode 100644
index 0000000..69692d7
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticatorActivity.kt
@@ -0,0 +1,9 @@
+package gq.kirmanak.mealient.ui.auth
+
+import androidx.appcompat.app.AppCompatActivity
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class AuthenticatorActivity : AppCompatActivity() {
+
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/authenticator_activity.xml b/app/src/main/res/layout/authenticator_activity.xml
new file mode 100644
index 0000000..515be2e
--- /dev/null
+++ b/app/src/main/res/layout/authenticator_activity.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/authenticator_graph.xml b/app/src/main/res/navigation/authenticator_graph.xml
new file mode 100644
index 0000000..12e281c
--- /dev/null
+++ b/app/src/main/res/navigation/authenticator_graph.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 532e868..c60f100 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -12,18 +12,20 @@
Ingredients
Instructions
This project is developed independently from the core Mealie project. It is NOT associated with the core Mealie developers. Any issues must be reported to the Mealient repository, NOT the Mealie repository.
- Okay
- Step: %d
- E-mail can\'t be empty
- Password can\'t be empty
URL can\'t be empty
- E-mail or password is incorrect.
Can\'t connect, check address.
Unexpected response. Is it Mealie?
- Something went wrong, please try again.
Check URL format: %s
Proceed
@string/fragment_authentication_unknown_error
@string/menu_main_toolbar_login
Login
+ Okay
+ Step: %d
+ E-mail can\'t be empty
+ Password can\'t be empty
+ E-mail or password is incorrect.
+ Something went wrong, please try again.
+ Mealient
+ mealientAuthToken
\ No newline at end of file
diff --git a/app/src/main/res/xml/account_authenticator.xml b/app/src/main/res/xml/account_authenticator.xml
new file mode 100644
index 0000000..95d0037
--- /dev/null
+++ b/app/src/main/res/xml/account_authenticator.xml
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file