Replace AccountManager with EncryptedSharedPreferences

This commit is contained in:
Kirill Kamakin
2022-04-08 20:00:53 +05:00
parent ba28f7d322
commit 7c081c199a
41 changed files with 243 additions and 722 deletions

View File

@@ -154,6 +154,9 @@ dependencies {
// https://developer.android.com/topic/libraries/architecture/datastore
implementation "androidx.datastore:datastore-preferences:1.0.0"
// https://developer.android.com/topic/security/data#include-library
implementation "androidx.security:security-crypto:1.0.0"
// https://github.com/junit-team/junit4/releases
testImplementation "junit:junit:4.13.2"

View File

@@ -15,27 +15,16 @@
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
tools:ignore="UnusedAttribute"
android:theme="@style/Theme.Mealient">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
android:theme="@style/Theme.Mealient">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<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>
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -5,6 +5,8 @@ import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import androidx.navigation.findNavController
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable
import dagger.hilt.android.AndroidEntryPoint
@@ -71,9 +73,11 @@ class MainActivity : AppCompatActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean {
Timber.v("onOptionsItemSelected() called with: item = $item")
val result = when (item.itemId) {
R.id.logout, R.id.login -> {
// When user clicks logout they don't want to be authorized
authViewModel.authRequested = item.itemId == R.id.login
R.id.login -> {
navigateToLogin()
true
}
R.id.logout -> {
authViewModel.logout()
true
}
@@ -81,4 +85,9 @@ class MainActivity : AppCompatActivity() {
}
return result
}
private fun navigateToLogin() {
Timber.v("navigateToLogin() called")
findNavController(binding.navHost.id).navigate("mealient://authenticate".toUri())
}
}

View File

@@ -6,7 +6,7 @@ interface AuthRepo {
val isAuthorizedFlow: Flow<Boolean>
suspend fun authenticate(username: String, password: String)
suspend fun authenticate(email: String, password: String)
suspend fun getAuthHeader(): String?
@@ -14,5 +14,5 @@ interface AuthRepo {
suspend fun logout()
fun invalidateAuthHeader(header: String)
suspend fun invalidateAuthHeader()
}

View File

@@ -0,0 +1,20 @@
package gq.kirmanak.mealient.data.auth
import kotlinx.coroutines.flow.Flow
interface AuthStorage {
val authHeaderFlow: Flow<String?>
suspend fun setAuthHeader(authHeader: String?)
suspend fun getAuthHeader(): String?
suspend fun setEmail(email: String?)
suspend fun getEmail(): String?
suspend fun setPassword(password: String?)
suspend fun getPassword(): String?
}

View File

@@ -23,7 +23,7 @@ class AuthDataSourceImpl @Inject constructor(
override suspend fun authenticate(username: String, password: String): String {
Timber.v("authenticate() called with: username = $username, password = $password")
val authService = authServiceFactory.provideService(needAuth = false)
val authService = authServiceFactory.provideService()
val response = sendRequest(authService, username, password)
val accessToken = parseToken(response)
Timber.v("authenticate() returned: $accessToken")

View File

@@ -1,9 +1,9 @@
package gq.kirmanak.mealient.data.auth.impl
import android.accounts.Account
import gq.kirmanak.mealient.data.auth.AuthDataSource
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.map
import timber.log.Timber
@@ -12,71 +12,41 @@ import javax.inject.Singleton
@Singleton
class AuthRepoImpl @Inject constructor(
private val accountManagerInteractor: AccountManagerInteractor,
private val authStorage: AuthStorage,
private val authDataSource: AuthDataSource,
) : AuthRepo {
override val isAuthorizedFlow: Flow<Boolean>
get() = accountManagerInteractor.accountUpdatesFlow()
.map { it.firstOrNull() }
.map { account ->
runCatchingExceptCancel { getAuthToken(account) }
.onFailure { Timber.e(it, "authHeaderObservable: can't get token") }
.getOrNull()
}.map { it != null }
get() = authStorage.authHeaderFlow.map { it != null }
override suspend fun authenticate(username: String, password: String) {
Timber.v("authenticate() called with: username = $username, password = $password")
val account = accountManagerInteractor.addAccount(username, password)
runCatchingExceptCancel {
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 authenticate(email: String, password: String) {
Timber.v("authenticate() called with: email = $email, password = $password")
authDataSource.authenticate(email, password)
.let { AUTH_HEADER_FORMAT.format(it) }
.let { authStorage.setAuthHeader(it) }
authStorage.setEmail(email)
authStorage.setPassword(password)
}
override suspend fun getAuthHeader(): String? = runCatchingExceptCancel {
Timber.v("getAuthHeader() called")
currentAccount()
?.let { getAuthToken(it) }
?.let { AUTH_HEADER_FORMAT.format(it) }
}.onFailure {
Timber.e(it, "getAuthHeader: can't request auth header")
}.getOrNull()
override suspend fun getAuthHeader(): String? = authStorage.getAuthHeader()
private suspend fun getAuthToken(account: Account?): String? {
return account?.let { accountManagerInteractor.getAuthToken(it) }
override suspend fun requireAuthHeader(): String = checkNotNull(getAuthHeader()) {
"Auth header is null when it was required"
}
private fun currentAccount(): Account? {
val account = accountManagerInteractor.getAccounts().firstOrNull()
Timber.v("currentAccount() returned: $account")
return account
}
override suspend fun requireAuthHeader(): String =
checkNotNull(getAuthHeader()) { "Auth header is null when it was required" }
override suspend fun logout() {
Timber.v("logout() called")
currentAccount()?.let { removeAccount(it) }
authStorage.setEmail(null)
authStorage.setPassword(null)
authStorage.setAuthHeader(null)
}
private suspend fun removeAccount(account: Account) {
Timber.v("removeAccount() called with: account = $account")
accountManagerInteractor.removeAccount(account)
}
override fun invalidateAuthHeader(header: String) {
Timber.v("invalidateAuthHeader() called with: header = $header")
val token = header.substringAfter("Bearer ")
if (token == header) {
Timber.w("invalidateAuthHeader: can't find token in $header")
} else {
accountManagerInteractor.invalidateAuthToken(token)
}
override suspend fun invalidateAuthHeader() {
Timber.v("invalidateAuthHeader() called")
val email = authStorage.getEmail() ?: return
val password = authStorage.getPassword() ?: return
runCatchingExceptCancel { authenticate(email, password) }
.onFailure { logout() } // Clear all known values to avoid reusing them
}
companion object {

View File

@@ -0,0 +1,64 @@
package gq.kirmanak.mealient.data.auth.impl
import android.content.SharedPreferences
import androidx.core.content.edit
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.di.AuthModule.Companion.ENCRYPTED
import gq.kirmanak.mealient.extensions.prefsChangeFlow
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.concurrent.Executors
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class AuthStorageImpl @Inject constructor(
@Named(ENCRYPTED) private val sharedPreferences: SharedPreferences,
) : AuthStorage {
override val authHeaderFlow: Flow<String?>
get() = sharedPreferences
.prefsChangeFlow { getString(AUTH_HEADER_KEY, null) }
.distinctUntilChanged()
private val singleThreadDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
override suspend fun setAuthHeader(authHeader: String?) {
putString(AUTH_HEADER_KEY, authHeader)
}
override suspend fun getAuthHeader(): String? = getString(AUTH_HEADER_KEY)
override suspend fun setEmail(email: String?) = putString(EMAIL_KEY, email)
override suspend fun getEmail(): String? = getString(EMAIL_KEY)
override suspend fun setPassword(password: String?) = putString(PASSWORD_KEY, password)
override suspend fun getPassword(): String? = getString(PASSWORD_KEY)
private suspend fun putString(
key: String,
value: String?
) = withContext(singleThreadDispatcher) {
Timber.v("putString() called with: key = $key, value = $value")
sharedPreferences.edit {
value?.let { putString(key, value) } ?: remove(key)
}
}
private suspend fun getString(key: String) = withContext(singleThreadDispatcher) {
val result = sharedPreferences.getString(key, null)
Timber.v("getString() called with: key = $key, returned: $result")
result
}
companion object {
private const val AUTH_HEADER_KEY = "authHeader"
private const val EMAIL_KEY = "email"
private const val PASSWORD_KEY = "password"
}
}

View File

@@ -19,7 +19,7 @@ class AuthenticationInterceptor @Inject constructor(
val currentHeader = authHeader ?: return chain.proceed(chain.request())
val response = proceedWithAuthHeader(chain, currentHeader)
if (listOf(401, 403).contains(response.code)) {
authRepo.invalidateAuthHeader(currentHeader)
runBlocking { authRepo.invalidateAuthHeader() }
} else {
return response
}

View File

@@ -1,34 +1,26 @@
package gq.kirmanak.mealient.data.network
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import gq.kirmanak.mealient.di.AUTH_OK_HTTP
import gq.kirmanak.mealient.di.NO_AUTH_OK_HTTP
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class RetrofitBuilder @Inject constructor(
@Named(AUTH_OK_HTTP) private val authOkHttpClient: OkHttpClient,
@Named(NO_AUTH_OK_HTTP) private val noAuthOkHttpClient: OkHttpClient,
class RetrofitBuilder(
private val okHttpClient: OkHttpClient,
private val json: Json
) {
@OptIn(ExperimentalSerializationApi::class)
fun buildRetrofit(baseUrl: String, needAuth: Boolean): Retrofit {
fun buildRetrofit(baseUrl: String): Retrofit {
Timber.v("buildRetrofit() called with: baseUrl = $baseUrl")
val contentType = "application/json".toMediaType()
val converterFactory = json.asConverterFactory(contentType)
val client = if (needAuth) authOkHttpClient else noAuthOkHttpClient
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.client(okHttpClient)
.addConverterFactory(converterFactory)
.build()
}

View File

@@ -13,28 +13,21 @@ class RetrofitServiceFactory<T>(
private val baseURLStorage: BaseURLStorage,
) : ServiceFactory<T> {
private val cache: MutableMap<ServiceParams, T> = mutableMapOf()
private val cache: MutableMap<String, T> = mutableMapOf()
override suspend fun provideService(
baseUrl: String?,
needAuth: Boolean,
): T = runCatchingExceptCancel {
override suspend fun provideService(baseUrl: String?): T = runCatchingExceptCancel {
Timber.v("provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}")
val url = baseUrl ?: baseURLStorage.requireBaseURL()
val params = ServiceParams(url, needAuth)
synchronized(cache) { cache[params] ?: createService(params, serviceClass) }
synchronized(cache) { cache[url] ?: createService(url, serviceClass) }
}.getOrElse {
Timber.e(it, "provideService: can't provide service for $baseUrl")
throw NetworkError.MalformedUrl(it)
}
private fun createService(serviceParams: ServiceParams, serviceClass: Class<T>): T {
Timber.v("createService() called with: serviceParams = $serviceParams, serviceClass = ${serviceClass.simpleName}")
val (url, needAuth) = serviceParams
val service = retrofitBuilder.buildRetrofit(url, needAuth).create(serviceClass)
cache[serviceParams] = service
private fun createService(url: String, serviceClass: Class<T>): T {
Timber.v("createService() called with: url = $url, serviceClass = ${serviceClass.simpleName}")
val service = retrofitBuilder.buildRetrofit(url).create(serviceClass)
cache[url] = service
return service
}
data class ServiceParams(val baseUrl: String, val needAuth: Boolean)
}

View File

@@ -2,5 +2,5 @@ package gq.kirmanak.mealient.data.network
interface ServiceFactory<T> {
suspend fun provideService(baseUrl: String? = null, needAuth: Boolean = true): T
suspend fun provideService(baseUrl: String? = null): T
}

View File

@@ -2,25 +2,29 @@ package gq.kirmanak.mealient.di
import android.accounts.AccountManager
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
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
import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl
import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl
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.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
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 kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton
@Module
@@ -28,13 +32,17 @@ import javax.inject.Singleton
interface AuthModule {
companion object {
const val ENCRYPTED = "encrypted"
@Provides
@Singleton
fun provideAuthServiceFactory(
retrofitBuilder: RetrofitBuilder,
@Named(NO_AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
baseURLStorage: BaseURLStorage,
): ServiceFactory<AuthService> = retrofitBuilder.createServiceFactory(baseURLStorage)
): ServiceFactory<AuthService> {
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
}
@Provides
@Singleton
@@ -44,10 +52,20 @@ interface AuthModule {
@Provides
@Singleton
fun provideAccountType(@ApplicationContext context: Context) = AccountParameters(
accountType = context.getString(R.string.account_type),
authTokenType = context.getString(R.string.auth_token_type),
)
@Named(ENCRYPTED)
fun provideEncryptedSharedPreferences(
@ApplicationContext applicationContext: Context,
): SharedPreferences {
val keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC
val mainKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec)
return EncryptedSharedPreferences.create(
ENCRYPTED,
mainKeyAlias,
applicationContext,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
}
@Binds
@@ -60,7 +78,5 @@ interface AuthModule {
@Binds
@Singleton
fun bindAccountManagerInteractor(
accountManagerInteractorImpl: AccountManagerInteractorImpl
): AccountManagerInteractor
fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage
}

View File

@@ -9,6 +9,9 @@ import gq.kirmanak.mealient.data.baseurl.*
import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton
@Module
@@ -20,9 +23,12 @@ interface BaseURLModule {
@Provides
@Singleton
fun provideVersionServiceFactory(
retrofitBuilder: RetrofitBuilder,
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
baseURLStorage: BaseURLStorage,
): ServiceFactory<VersionService> = retrofitBuilder.createServiceFactory(baseURLStorage)
): ServiceFactory<VersionService> {
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
}
}
@Binds

View File

@@ -19,6 +19,9 @@ import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl
import gq.kirmanak.mealient.data.recipes.network.RecipeService
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton
@Module
@@ -46,9 +49,12 @@ interface RecipeModule {
@Provides
@Singleton
fun provideRecipeServiceFactory(
retrofitBuilder: RetrofitBuilder,
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
baseURLStorage: BaseURLStorage,
): ServiceFactory<RecipeService> = retrofitBuilder.createServiceFactory(baseURLStorage)
): ServiceFactory<RecipeService> {
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
}
@Provides
@Singleton

View File

@@ -4,6 +4,7 @@ import androidx.activity.OnBackPressedDispatcher
import androidx.activity.addCallback
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
@@ -33,4 +34,8 @@ fun OnBackPressedDispatcher.backPressedFlow(): Flow<Unit> = callbackFlow {
inline fun <T> Fragment.collectWithViewLifecycle(
flow: Flow<T>,
crossinline collector: suspend (T) -> Unit,
) = viewLifecycleOwner.lifecycleScope.launch { flow.collect(collector) }
) = launchWithViewLifecycle { flow.collect(collector) }
fun Fragment.launchWithViewLifecycle(
block: suspend CoroutineScope.() -> Unit,
) = viewLifecycleOwner.lifecycleScope.launch(block = block)

View File

@@ -1,6 +1,7 @@
package gq.kirmanak.mealient.extensions
import android.app.Activity
import android.content.SharedPreferences
import android.os.Build
import android.view.View
import android.view.WindowInsets
@@ -106,4 +107,17 @@ fun EditText.checkIfInputIsEmpty(
suspend fun EditText.waitUntilNotEmpty() {
textChangesFlow().filterNotNull().first { it.isNotEmpty() }
Timber.v("waitUntilNotEmpty() returned")
}
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> SharedPreferences.prefsChangeFlow(
valueReader: SharedPreferences.() -> T,
): Flow<T> = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, _ ->
val value = prefs.valueReader()
trySend(value).logErrors("prefsChangeFlow")
}
trySend(valueReader())
registerOnSharedPreferenceChangeListener(listener)
awaitClose { unregisterOnSharedPreferenceChangeListener(listener) }
}

View File

@@ -1,137 +0,0 @@
package gq.kirmanak.mealient.service.auth
import android.accounts.*
import android.content.Context
import android.os.Bundle
import dagger.hilt.android.qualifiers.ApplicationContext
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.service.auth.AuthenticatorException.*
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
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) {
private val accountType: String
get() = accountParameters.accountType
private val authTokenType: String
get() = accountParameters.authTokenType
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 = try {
checkAccountType(account.type)
checkAuthTokenType(authTokenType)
accountManager.getPassword(account) ?: throw AccountNotFound(account)
} catch (e: AuthenticatorException) {
Timber.e(e, "getAuthToken: validation failure")
return e.bundle
}
val token = runCatchingExceptCancel {
runBlocking {
withTimeout(10000) {
authDataSource.authenticate(account.name, password)
}
}
}.getOrElse {
return when (it) {
is NetworkError.NotMealie -> NotMealie.bundle
is NetworkError.Unauthorized -> Unauthorized.bundle
else -> throw NetworkErrorException(it)
}
}
return Bundle().apply {
putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
putString(AccountManager.KEY_ACCOUNT_TYPE, 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 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(
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 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 != this.accountType) {
throw UnsupportedAccountType(accountType)
}
}
private fun checkAuthTokenType(authTokenType: String) {
if (authTokenType != this.authTokenType) {
throw UnsupportedAuthTokenType(authTokenType)
}
}
}

View File

@@ -1,19 +0,0 @@
package gq.kirmanak.mealient.service.auth
import android.accounts.Account
import kotlinx.coroutines.flow.Flow
interface AccountManagerInteractor {
fun getAccounts(): Array<Account>
suspend fun addAccount(email: String, password: String): Account
suspend fun getAuthToken(account: Account): String
fun accountUpdatesFlow(): Flow<Array<Account>>
suspend fun removeAccount(account: Account)
fun invalidateAuthToken(token: String)
}

View File

@@ -1,70 +0,0 @@
package gq.kirmanak.mealient.service.auth
import android.accounts.Account
import android.accounts.AccountManager
import kotlinx.coroutines.flow.Flow
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AccountManagerInteractorImpl @Inject constructor(
private val accountManager: AccountManager,
private val accountParameters: AccountParameters,
) : AccountManagerInteractor {
private val accountType: String
get() = accountParameters.accountType
private val authTokenType: String
get() = accountParameters.authTokenType
override fun getAccounts(): Array<Account> {
Timber.v("getAccounts() called")
val accounts = accountManager.getAccountsByType(accountType)
Timber.v("getAccounts() returned: ${accounts.contentToString()}")
return accounts
}
override suspend fun addAccount(email: String, password: String): Account {
Timber.v("addAccount() called with: email = $email, password = $password")
val account = Account(email, accountType)
removeAccount(account) // Remove account if it was created earlier
accountManager.addAccountExplicitly(account, password, null)
return account
}
override suspend fun getAuthToken(account: Account): String {
Timber.v("getAuthToken() called with: account = $account")
val bundle = accountManager.getAuthToken(
account,
authTokenType,
null,
null,
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>> {
Timber.v("accountUpdatesFlow() called")
return accountManager.accountUpdatesFlow(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()}")
}
override fun invalidateAuthToken(token: String) {
Timber.v("resetAuthToken() called with: token = $token")
accountManager.invalidateAuthToken(accountType, token)
}
}

View File

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

View File

@@ -1,53 +0,0 @@
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 gq.kirmanak.mealient.extensions.logErrors
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext
import timber.log.Timber
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" }
internal fun Bundle.result(): Boolean = getBoolean(KEY_BOOLEAN_RESULT)
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.contentToString()}")
val filtered = accounts.filter { types.contains(it.type) }.toTypedArray()
Timber.d("accountUpdatesFlow: filtered accounts = ${filtered.contentToString()}")
trySendBlocking(filtered).logErrors("accountUpdatesFlow")
}
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

@@ -1,16 +0,0 @@
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

@@ -1,49 +0,0 @@
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 Unauthorized : AuthenticatorException(
errorCode = ErrorCode.Unauthorized.ordinal,
errorMessage = "E-mail or password weren't correct"
)
object NotMealie : AuthenticatorException(
errorCode = ErrorCode.NotMealie.ordinal,
errorMessage = "Base URL must be pointing at a non Mealie server"
)
enum class ErrorCode { NotMealie, Unauthorized; }
}

View File

@@ -1,19 +0,0 @@
package gq.kirmanak.mealient.ui.addaccount
import android.os.Bundle
import android.os.PersistableBundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
@AndroidEntryPoint
class AddAccountActivity : AppCompatActivity() {
private val viewModel by viewModels<AddAccountViewModel>()
override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
super.onCreate(savedInstanceState, persistentState)
supportActionBar?.title = getString(R.string.app_name)
}
}

View File

@@ -1,64 +0,0 @@
package gq.kirmanak.mealient.ui.addaccount
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint
class AddAccountFragment : Fragment(R.layout.fragment_authentication) {
private val binding by viewBinding(FragmentAuthenticationBinding::bind)
private val viewModel by activityViewModels<AddAccountViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
binding.button.setOnClickListener { onLoginClicked() }
}
private fun onLoginClicked(): Unit = with(binding) {
Timber.v("onLoginClicked() called")
val email: String = emailInput.checkIfInputIsEmpty(
inputLayout = emailInputLayout,
lifecycleOwner = viewLifecycleOwner,
stringId = R.string.fragment_authentication_email_input_empty,
) ?: return
val pass: String = passwordInput.checkIfInputIsEmpty(
inputLayout = passwordInputLayout,
lifecycleOwner = viewLifecycleOwner,
stringId = R.string.fragment_authentication_password_input_empty,
trim = false,
) ?: return
button.isClickable = false
viewLifecycleOwner.lifecycleScope.launch {
onAuthenticationResult(viewModel.authenticate(email, pass))
}
}
private fun onAuthenticationResult(result: Result<Unit>) {
Timber.v("onAuthenticationResult() called with: result = $result")
if (result.isSuccess) {
TODO("Implement authentication success")
}
binding.passwordInputLayout.error = when (result.exceptionOrNull()) {
is NetworkError.Unauthorized -> getString(R.string.fragment_authentication_credentials_incorrect)
else -> null
}
binding.button.isClickable = true
}
}

View File

@@ -1,20 +0,0 @@
package gq.kirmanak.mealient.ui.addaccount
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import timber.log.Timber
@HiltViewModel
class AddAccountViewModel(
private val authRepo: AuthRepo,
) : ViewModel() {
suspend fun authenticate(username: String, password: String) = runCatchingExceptCancel {
Timber.v("authenticate() called with: username = $username, password = $password")
authRepo.authenticate(username, password)
}.onFailure {
Timber.e(it, "authenticate: can't authenticate")
}
}

View File

@@ -5,7 +5,6 @@ import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint
@@ -13,8 +12,7 @@ import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.extensions.executeOnceOnBackPressed
import kotlinx.coroutines.launch
import gq.kirmanak.mealient.extensions.launchWithViewLifecycle
import timber.log.Timber
@AndroidEntryPoint
@@ -22,12 +20,6 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
private val binding by viewBinding(FragmentAuthenticationBinding::bind)
private val viewModel by activityViewModels<AuthenticationViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState")
executeOnceOnBackPressed { viewModel.authRequested = false }
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
@@ -53,9 +45,7 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
) ?: return
button.isClickable = false
viewLifecycleOwner.lifecycleScope.launch {
onAuthenticationResult(viewModel.authenticate(email, pass))
}
launchWithViewLifecycle { onAuthenticationResult(viewModel.authenticate(email, pass)) }
}
private fun onAuthenticationResult(result: Result<Unit>) {

View File

@@ -4,22 +4,19 @@ import timber.log.Timber
enum class AuthenticationState {
AUTHORIZED,
AUTH_REQUESTED,
UNAUTHORIZED,
UNKNOWN;
companion object {
fun determineState(
isLoginRequested: Boolean,
showLoginButton: Boolean,
isAuthorized: Boolean,
): AuthenticationState {
Timber.v("determineState() called with: isLoginRequested = $isLoginRequested, showLoginButton = $showLoginButton, isAuthorized = $isAuthorized")
Timber.v("determineState() called with: showLoginButton = $showLoginButton, isAuthorized = $isAuthorized")
val result = when {
!showLoginButton -> UNKNOWN
isAuthorized -> AUTHORIZED
isLoginRequested -> AUTH_REQUESTED
else -> UNAUTHORIZED
}
Timber.v("determineState() returned: $result")

View File

@@ -18,21 +18,18 @@ class AuthenticationViewModel @Inject constructor(
private val authRepo: AuthRepo,
) : ViewModel() {
private val authRequestsFlow = MutableStateFlow(false)
private val showLoginButtonFlow = MutableStateFlow(false)
private val authenticationStateFlow = combine(
authRequestsFlow,
showLoginButtonFlow,
authRepo.isAuthorizedFlow,
AuthenticationState::determineState
)
val authenticationStateLive: LiveData<AuthenticationState>
get() = authenticationStateFlow.asLiveData()
var authRequested: Boolean by authRequestsFlow::value
var showLoginButton: Boolean by showLoginButtonFlow::value
suspend fun authenticate(username: String, password: String) = runCatchingExceptCancel {
authRepo.authenticate(username, password)
suspend fun authenticate(email: String, password: String) = runCatchingExceptCancel {
authRepo.authenticate(email, password)
}.onFailure {
Timber.e(it, "authenticate: can't authenticate")
}

View File

@@ -11,6 +11,7 @@ import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.extensions.launchWithViewLifecycle
import timber.log.Timber
@AndroidEntryPoint
@@ -22,7 +23,6 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
viewModel.screenState.observe(viewLifecycleOwner, ::updateState)
binding.button.setOnClickListener(::onProceedClick)
}
@@ -33,16 +33,16 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
lifecycleOwner = viewLifecycleOwner,
stringId = R.string.fragment_baseurl_url_input_empty,
) ?: return
viewModel.saveBaseUrl(url)
launchWithViewLifecycle { onCheckURLResult(viewModel.saveBaseUrl(url)) }
}
private fun updateState(baseURLScreenState: BaseURLScreenState) {
Timber.v("updateState() called with: baseURLScreenState = $baseURLScreenState")
if (baseURLScreenState.navigateNext) {
private fun onCheckURLResult(result: Result<Unit>) {
Timber.v("onCheckURLResult() called with: result = $result")
if (result.isSuccess) {
findNavController().navigate(BaseURLFragmentDirections.actionBaseURLFragmentToRecipesFragment())
return
}
binding.urlInputLayout.error = when (val exception = baseURLScreenState.error) {
binding.urlInputLayout.error = when (val exception = result.exceptionOrNull()) {
is NetworkError.NoServerConnection -> getString(R.string.fragment_base_url_no_connection)
is NetworkError.NotMealie -> getString(R.string.fragment_base_url_unexpected_response)
is NetworkError.MalformedUrl -> {

View File

@@ -1,8 +0,0 @@
package gq.kirmanak.mealient.ui.baseurl
import gq.kirmanak.mealient.data.network.NetworkError
data class BaseURLScreenState(
val error: NetworkError? = null,
val navigateNext: Boolean = false,
)

View File

@@ -1,14 +1,10 @@
package gq.kirmanak.mealient.ui.baseurl
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.network.NetworkError
import kotlinx.coroutines.launch
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import timber.log.Timber
import javax.inject.Inject
@@ -18,35 +14,21 @@ class BaseURLViewModel @Inject constructor(
private val versionDataSource: VersionDataSource,
) : ViewModel() {
private val _screenState = MutableLiveData(BaseURLScreenState())
var currentScreenState: BaseURLScreenState
get() = _screenState.value!!
private set(value) {
_screenState.value = value
}
val screenState: LiveData<BaseURLScreenState>
get() = _screenState
fun saveBaseUrl(baseURL: String) {
suspend fun saveBaseUrl(baseURL: String): Result<Unit> {
Timber.v("saveBaseUrl() called with: baseURL = $baseURL")
val hasPrefix = ALLOWED_PREFIXES.any { baseURL.startsWith(it) }
val url = baseURL.takeIf { hasPrefix } ?: WITH_PREFIX_FORMAT.format(baseURL)
viewModelScope.launch { checkBaseURL(url) }
return checkBaseURL(url)
}
private suspend fun checkBaseURL(baseURL: String) {
private suspend fun checkBaseURL(baseURL: String): Result<Unit> {
Timber.v("checkBaseURL() called with: baseURL = $baseURL")
val version = try {
val result = runCatchingExceptCancel {
// If it returns proper version info then it must be a Mealie
versionDataSource.getVersionInfo(baseURL)
} catch (e: NetworkError) {
Timber.e(e, "checkBaseURL: can't get version info")
currentScreenState = BaseURLScreenState(e, false)
return
}
Timber.d("checkBaseURL: version is $version")
baseURLStorage.storeBaseURL(baseURL)
currentScreenState = BaseURLScreenState(null, true)
return result.map { }
}
companion object {

View File

@@ -14,7 +14,6 @@ import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.FragmentRecipesBinding
import gq.kirmanak.mealient.extensions.collectWithViewLifecycle
import gq.kirmanak.mealient.extensions.refreshRequestFlow
import gq.kirmanak.mealient.ui.auth.AuthenticationState
import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel
import timber.log.Timber
@@ -24,19 +23,6 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
private val viewModel by viewModels<RecipeViewModel>()
private val authViewModel by activityViewModels<AuthenticationViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState")
authViewModel.authenticationStateLive.observe(this, ::onAuthStateChange)
}
private fun onAuthStateChange(authenticationState: AuthenticationState) {
Timber.v("onAuthStateChange() called with: authenticationState = $authenticationState")
if (authenticationState == AuthenticationState.AUTH_REQUESTED) {
findNavController().navigate(RecipesFragmentDirections.actionRecipesFragmentToAuthenticationFragment())
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")

View File

@@ -9,15 +9,16 @@
android:id="@+id/authenticationFragment"
android:name="gq.kirmanak.mealient.ui.auth.AuthenticationFragment"
android:label="AuthenticationFragment"
tools:layout="@layout/fragment_authentication" />
tools:layout="@layout/fragment_authentication">
<deepLink
android:id="@+id/deepLink"
app:uri="mealient://authenticate" />
</fragment>
<fragment
android:id="@+id/recipesFragment"
android:name="gq.kirmanak.mealient.ui.recipes.RecipesFragment"
android:label="fragment_recipes"
tools:layout="@layout/fragment_recipes">
<action
android:id="@+id/action_recipesFragment_to_authenticationFragment"
app:destination="@id/authenticationFragment" />
<action
android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
app:destination="@id/recipeInfoFragment" />

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="@string/account_type"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" />

View File

@@ -32,7 +32,7 @@ class AuthDataSourceImplTest {
fun setUp() {
MockKAnnotations.init(this)
subject = AuthDataSourceImpl(authServiceFactory, NetworkModule.createJson())
coEvery { authServiceFactory.provideService(any(), eq(false)) } returns authService
coEvery { authServiceFactory.provideService(any()) } returns authService
}
@Test
@@ -72,7 +72,7 @@ class AuthDataSourceImplTest {
@Test(expected = MalformedUrl::class)
fun `when authenticate and provideService throws then MalformedUrl`() = runTest {
coEvery {
authServiceFactory.provideService(any(), eq(false))
authServiceFactory.provideService(any())
} throws MalformedUrl(RuntimeException())
callAuthenticate()
}

View File

@@ -1,22 +0,0 @@
package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.service.auth.AccountManagerInteractor
import io.mockk.MockKAnnotations
import io.mockk.impl.annotations.MockK
import org.junit.Before
class AuthRepoImplTest {
@MockK
lateinit var accountManagerInteractor: AccountManagerInteractor
lateinit var subject: AuthRepoImpl
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = AuthRepoImpl(accountManagerInteractor)
}
// TODO write the actual tests
}

View File

@@ -66,7 +66,7 @@ class AuthenticationInterceptorTest {
coVerifySequence {
authRepo.getAuthHeader()
authRepo.invalidateAuthHeader(TEST_AUTH_HEADER)
authRepo.invalidateAuthHeader()
authRepo.getAuthHeader()
}
}

View File

@@ -33,7 +33,7 @@ class RetrofitServiceFactoryTest {
fun setUp() {
MockKAnnotations.init(this)
subject = retrofitBuilder.createServiceFactory(baseURLStorage)
coEvery { retrofitBuilder.buildRetrofit(any(), eq(true)) } returns retrofit
coEvery { retrofitBuilder.buildRetrofit(any()) } returns retrofit
every { retrofit.create(eq(VersionService::class.java)) } returns versionService
coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL
}
@@ -53,7 +53,7 @@ class RetrofitServiceFactoryTest {
fun `when provideService called twice then builder called once`() = runTest {
subject.provideService()
subject.provideService()
coVerifyAll { retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL), eq(true)) }
coVerifyAll { retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL)) }
}
@Test
@@ -61,8 +61,8 @@ class RetrofitServiceFactoryTest {
subject.provideService()
subject.provideService("new url")
coVerifyAll {
retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL), eq(true))
retrofitBuilder.buildRetrofit(eq("new url"), eq(true))
retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL))
retrofitBuilder.buildRetrofit(eq("new url"))
}
}
}

View File

@@ -1,10 +1,8 @@
package gq.kirmanak.mealient.ui.baseurl
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.RobolectricTest
import io.mockk.MockKAnnotations
@@ -34,35 +32,6 @@ class BaseURLViewModelTest : RobolectricTest() {
subject = BaseURLViewModel(baseURLStorage, versionDataSource)
}
@Test
fun `when initialized then error is null`() {
assertThat(subject.currentScreenState.error).isNull()
}
@Test
fun `when initialized then navigateNext is false`() {
assertThat(subject.currentScreenState.navigateNext).isFalse()
}
@Test
fun `when saveBaseUrl and getVersionInfo throws then state is correct`() = runTest {
val error = NetworkError.Unauthorized(RuntimeException())
coEvery { versionDataSource.getVersionInfo(eq(TEST_BASE_URL)) } throws error
subject.saveBaseUrl(TEST_BASE_URL)
advanceUntilIdle()
assertThat(subject.currentScreenState).isEqualTo(BaseURLScreenState(error, false))
}
@Test
fun `when saveBaseUrl and getVersionInfo returns result then state is correct`() = runTest {
coEvery {
versionDataSource.getVersionInfo(eq(TEST_BASE_URL))
} returns VersionInfo(true, "0.5.6", true)
subject.saveBaseUrl(TEST_BASE_URL)
advanceUntilIdle()
assertThat(subject.currentScreenState).isEqualTo(BaseURLScreenState(null, true))
}
@Test
fun `when saveBaseUrl and getVersionInfo returns result then saves to storage`() = runTest {
coEvery {