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 // https://developer.android.com/topic/libraries/architecture/datastore
implementation "androidx.datastore:datastore-preferences:1.0.0" 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 // https://github.com/junit-team/junit4/releases
testImplementation "junit:junit:4.13.2" testImplementation "junit:junit:4.13.2"

View File

@@ -25,17 +25,6 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </manifest>

View File

@@ -5,6 +5,8 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity 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.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@@ -71,9 +73,11 @@ class MainActivity : AppCompatActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
Timber.v("onOptionsItemSelected() called with: item = $item") Timber.v("onOptionsItemSelected() called with: item = $item")
val result = when (item.itemId) { val result = when (item.itemId) {
R.id.logout, R.id.login -> { R.id.login -> {
// When user clicks logout they don't want to be authorized navigateToLogin()
authViewModel.authRequested = item.itemId == R.id.login true
}
R.id.logout -> {
authViewModel.logout() authViewModel.logout()
true true
} }
@@ -81,4 +85,9 @@ class MainActivity : AppCompatActivity() {
} }
return result 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> val isAuthorizedFlow: Flow<Boolean>
suspend fun authenticate(username: String, password: String) suspend fun authenticate(email: String, password: String)
suspend fun getAuthHeader(): String? suspend fun getAuthHeader(): String?
@@ -14,5 +14,5 @@ interface AuthRepo {
suspend fun logout() 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 { override suspend fun authenticate(username: String, password: String): String {
Timber.v("authenticate() called with: username = $username, password = $password") 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 response = sendRequest(authService, username, password)
val accessToken = parseToken(response) val accessToken = parseToken(response)
Timber.v("authenticate() returned: $accessToken") Timber.v("authenticate() returned: $accessToken")

View File

@@ -1,9 +1,9 @@
package gq.kirmanak.mealient.data.auth.impl 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.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.service.auth.AccountManagerInteractor
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import timber.log.Timber import timber.log.Timber
@@ -12,71 +12,41 @@ import javax.inject.Singleton
@Singleton @Singleton
class AuthRepoImpl @Inject constructor( class AuthRepoImpl @Inject constructor(
private val accountManagerInteractor: AccountManagerInteractor, private val authStorage: AuthStorage,
private val authDataSource: AuthDataSource,
) : AuthRepo { ) : AuthRepo {
override val isAuthorizedFlow: Flow<Boolean> override val isAuthorizedFlow: Flow<Boolean>
get() = accountManagerInteractor.accountUpdatesFlow() get() = authStorage.authHeaderFlow.map { it != null }
.map { it.firstOrNull() }
.map { account ->
runCatchingExceptCancel { getAuthToken(account) }
.onFailure { Timber.e(it, "authHeaderObservable: can't get token") }
.getOrNull()
}.map { it != null }
override suspend fun authenticate(username: String, password: String) { override suspend fun authenticate(email: String, password: String) {
Timber.v("authenticate() called with: username = $username, password = $password") Timber.v("authenticate() called with: email = $email, password = $password")
val account = accountManagerInteractor.addAccount(username, password) authDataSource.authenticate(email, password)
runCatchingExceptCancel { .let { AUTH_HEADER_FORMAT.format(it) }
getAuthToken(account) // Try to get token to check if password is correct .let { authStorage.setAuthHeader(it) }
}.onFailure { authStorage.setEmail(email)
Timber.e(it, "authenticate: can't authorize") authStorage.setPassword(password)
removeAccount(account) // Remove account with incorrect password
}.onSuccess {
Timber.d("authenticate: successfully authorized")
}.getOrThrow() // Throw error to show it to user
} }
override suspend fun getAuthHeader(): String? = runCatchingExceptCancel { override suspend fun getAuthHeader(): String? = authStorage.getAuthHeader()
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()
private suspend fun getAuthToken(account: Account?): String? { override suspend fun requireAuthHeader(): String = checkNotNull(getAuthHeader()) {
return account?.let { accountManagerInteractor.getAuthToken(it) } "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() { override suspend fun logout() {
Timber.v("logout() called") Timber.v("logout() called")
currentAccount()?.let { removeAccount(it) } authStorage.setEmail(null)
authStorage.setPassword(null)
authStorage.setAuthHeader(null)
} }
private suspend fun removeAccount(account: Account) { override suspend fun invalidateAuthHeader() {
Timber.v("removeAccount() called with: account = $account") Timber.v("invalidateAuthHeader() called")
accountManagerInteractor.removeAccount(account) val email = authStorage.getEmail() ?: return
} val password = authStorage.getPassword() ?: return
runCatchingExceptCancel { authenticate(email, password) }
override fun invalidateAuthHeader(header: String) { .onFailure { logout() } // Clear all known values to avoid reusing them
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)
}
} }
companion object { 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 currentHeader = authHeader ?: return chain.proceed(chain.request())
val response = proceedWithAuthHeader(chain, currentHeader) val response = proceedWithAuthHeader(chain, currentHeader)
if (listOf(401, 403).contains(response.code)) { if (listOf(401, 403).contains(response.code)) {
authRepo.invalidateAuthHeader(currentHeader) runBlocking { authRepo.invalidateAuthHeader() }
} else { } else {
return response return response
} }

View File

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

View File

@@ -13,28 +13,21 @@ class RetrofitServiceFactory<T>(
private val baseURLStorage: BaseURLStorage, private val baseURLStorage: BaseURLStorage,
) : ServiceFactory<T> { ) : ServiceFactory<T> {
private val cache: MutableMap<ServiceParams, T> = mutableMapOf() private val cache: MutableMap<String, T> = mutableMapOf()
override suspend fun provideService( override suspend fun provideService(baseUrl: String?): T = runCatchingExceptCancel {
baseUrl: String?,
needAuth: Boolean,
): T = runCatchingExceptCancel {
Timber.v("provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}") Timber.v("provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}")
val url = baseUrl ?: baseURLStorage.requireBaseURL() val url = baseUrl ?: baseURLStorage.requireBaseURL()
val params = ServiceParams(url, needAuth) synchronized(cache) { cache[url] ?: createService(url, serviceClass) }
synchronized(cache) { cache[params] ?: createService(params, serviceClass) }
}.getOrElse { }.getOrElse {
Timber.e(it, "provideService: can't provide service for $baseUrl") Timber.e(it, "provideService: can't provide service for $baseUrl")
throw NetworkError.MalformedUrl(it) throw NetworkError.MalformedUrl(it)
} }
private fun createService(serviceParams: ServiceParams, serviceClass: Class<T>): T { private fun createService(url: String, serviceClass: Class<T>): T {
Timber.v("createService() called with: serviceParams = $serviceParams, serviceClass = ${serviceClass.simpleName}") Timber.v("createService() called with: url = $url, serviceClass = ${serviceClass.simpleName}")
val (url, needAuth) = serviceParams val service = retrofitBuilder.buildRetrofit(url).create(serviceClass)
val service = retrofitBuilder.buildRetrofit(url, needAuth).create(serviceClass) cache[url] = service
cache[serviceParams] = service
return 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> { 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.accounts.AccountManager
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
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.impl.AuthDataSourceImpl import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl
import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl
import gq.kirmanak.mealient.data.auth.impl.AuthService import gq.kirmanak.mealient.data.auth.impl.AuthService
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.network.RetrofitBuilder import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory import gq.kirmanak.mealient.data.network.createServiceFactory
import gq.kirmanak.mealient.service.auth.AccountManagerInteractor import kotlinx.serialization.json.Json
import gq.kirmanak.mealient.service.auth.AccountManagerInteractorImpl import okhttp3.OkHttpClient
import gq.kirmanak.mealient.service.auth.AccountParameters import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -28,13 +32,17 @@ import javax.inject.Singleton
interface AuthModule { interface AuthModule {
companion object { companion object {
const val ENCRYPTED = "encrypted"
@Provides @Provides
@Singleton @Singleton
fun provideAuthServiceFactory( fun provideAuthServiceFactory(
retrofitBuilder: RetrofitBuilder, @Named(NO_AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
baseURLStorage: BaseURLStorage, baseURLStorage: BaseURLStorage,
): ServiceFactory<AuthService> = retrofitBuilder.createServiceFactory(baseURLStorage) ): ServiceFactory<AuthService> {
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
}
@Provides @Provides
@Singleton @Singleton
@@ -44,11 +52,21 @@ interface AuthModule {
@Provides @Provides
@Singleton @Singleton
fun provideAccountType(@ApplicationContext context: Context) = AccountParameters( @Named(ENCRYPTED)
accountType = context.getString(R.string.account_type), fun provideEncryptedSharedPreferences(
authTokenType = context.getString(R.string.auth_token_type), @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 @Binds
@Singleton @Singleton
@@ -60,7 +78,5 @@ interface AuthModule {
@Binds @Binds
@Singleton @Singleton
fun bindAccountManagerInteractor( fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage
accountManagerInteractorImpl: AccountManagerInteractorImpl
): AccountManagerInteractor
} }

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.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 kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -20,9 +23,12 @@ interface BaseURLModule {
@Provides @Provides
@Singleton @Singleton
fun provideVersionServiceFactory( fun provideVersionServiceFactory(
retrofitBuilder: RetrofitBuilder, @Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
baseURLStorage: BaseURLStorage, baseURLStorage: BaseURLStorage,
): ServiceFactory<VersionService> = retrofitBuilder.createServiceFactory(baseURLStorage) ): ServiceFactory<VersionService> {
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
}
} }
@Binds @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.RecipeDataSource
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl
import gq.kirmanak.mealient.data.recipes.network.RecipeService import gq.kirmanak.mealient.data.recipes.network.RecipeService
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -46,9 +49,12 @@ interface RecipeModule {
@Provides @Provides
@Singleton @Singleton
fun provideRecipeServiceFactory( fun provideRecipeServiceFactory(
retrofitBuilder: RetrofitBuilder, @Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
baseURLStorage: BaseURLStorage, baseURLStorage: BaseURLStorage,
): ServiceFactory<RecipeService> = retrofitBuilder.createServiceFactory(baseURLStorage) ): ServiceFactory<RecipeService> {
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
}
@Provides @Provides
@Singleton @Singleton

View File

@@ -4,6 +4,7 @@ import androidx.activity.OnBackPressedDispatcher
import androidx.activity.addCallback import androidx.activity.addCallback
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -33,4 +34,8 @@ fun OnBackPressedDispatcher.backPressedFlow(): Flow<Unit> = callbackFlow {
inline fun <T> Fragment.collectWithViewLifecycle( inline fun <T> Fragment.collectWithViewLifecycle(
flow: Flow<T>, flow: Flow<T>,
crossinline collector: suspend (T) -> Unit, 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 package gq.kirmanak.mealient.extensions
import android.app.Activity import android.app.Activity
import android.content.SharedPreferences
import android.os.Build import android.os.Build
import android.view.View import android.view.View
import android.view.WindowInsets import android.view.WindowInsets
@@ -107,3 +108,16 @@ suspend fun EditText.waitUntilNotEmpty() {
textChangesFlow().filterNotNull().first { it.isNotEmpty() } textChangesFlow().filterNotNull().first { it.isNotEmpty() }
Timber.v("waitUntilNotEmpty() returned") 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.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import by.kirich1409.viewbindingdelegate.viewBinding import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint 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.data.network.NetworkError
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.extensions.executeOnceOnBackPressed import gq.kirmanak.mealient.extensions.launchWithViewLifecycle
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
@@ -22,12 +20,6 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
private val binding by viewBinding(FragmentAuthenticationBinding::bind) private val binding by viewBinding(FragmentAuthenticationBinding::bind)
private val viewModel by activityViewModels<AuthenticationViewModel>() 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState") Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
@@ -53,9 +45,7 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
) ?: return ) ?: return
button.isClickable = false button.isClickable = false
viewLifecycleOwner.lifecycleScope.launch { launchWithViewLifecycle { onAuthenticationResult(viewModel.authenticate(email, pass)) }
onAuthenticationResult(viewModel.authenticate(email, pass))
}
} }
private fun onAuthenticationResult(result: Result<Unit>) { private fun onAuthenticationResult(result: Result<Unit>) {

View File

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

View File

@@ -18,21 +18,18 @@ class AuthenticationViewModel @Inject constructor(
private val authRepo: AuthRepo, private val authRepo: AuthRepo,
) : ViewModel() { ) : ViewModel() {
private val authRequestsFlow = MutableStateFlow(false)
private val showLoginButtonFlow = MutableStateFlow(false) private val showLoginButtonFlow = MutableStateFlow(false)
private val authenticationStateFlow = combine( private val authenticationStateFlow = combine(
authRequestsFlow,
showLoginButtonFlow, showLoginButtonFlow,
authRepo.isAuthorizedFlow, authRepo.isAuthorizedFlow,
AuthenticationState::determineState AuthenticationState::determineState
) )
val authenticationStateLive: LiveData<AuthenticationState> val authenticationStateLive: LiveData<AuthenticationState>
get() = authenticationStateFlow.asLiveData() get() = authenticationStateFlow.asLiveData()
var authRequested: Boolean by authRequestsFlow::value
var showLoginButton: Boolean by showLoginButtonFlow::value var showLoginButton: Boolean by showLoginButtonFlow::value
suspend fun authenticate(username: String, password: String) = runCatchingExceptCancel { suspend fun authenticate(email: String, password: String) = runCatchingExceptCancel {
authRepo.authenticate(username, password) authRepo.authenticate(email, password)
}.onFailure { }.onFailure {
Timber.e(it, "authenticate: can't authenticate") 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.data.network.NetworkError
import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.extensions.launchWithViewLifecycle
import timber.log.Timber import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
@@ -22,7 +23,6 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState") Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
viewModel.screenState.observe(viewLifecycleOwner, ::updateState)
binding.button.setOnClickListener(::onProceedClick) binding.button.setOnClickListener(::onProceedClick)
} }
@@ -33,16 +33,16 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
lifecycleOwner = viewLifecycleOwner, lifecycleOwner = viewLifecycleOwner,
stringId = R.string.fragment_baseurl_url_input_empty, stringId = R.string.fragment_baseurl_url_input_empty,
) ?: return ) ?: return
viewModel.saveBaseUrl(url) launchWithViewLifecycle { onCheckURLResult(viewModel.saveBaseUrl(url)) }
} }
private fun updateState(baseURLScreenState: BaseURLScreenState) { private fun onCheckURLResult(result: Result<Unit>) {
Timber.v("updateState() called with: baseURLScreenState = $baseURLScreenState") Timber.v("onCheckURLResult() called with: result = $result")
if (baseURLScreenState.navigateNext) { if (result.isSuccess) {
findNavController().navigate(BaseURLFragmentDirections.actionBaseURLFragmentToRecipesFragment()) findNavController().navigate(BaseURLFragmentDirections.actionBaseURLFragmentToRecipesFragment())
return 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.NoServerConnection -> getString(R.string.fragment_base_url_no_connection)
is NetworkError.NotMealie -> getString(R.string.fragment_base_url_unexpected_response) is NetworkError.NotMealie -> getString(R.string.fragment_base_url_unexpected_response)
is NetworkError.MalformedUrl -> { 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 package gq.kirmanak.mealient.ui.baseurl
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionDataSource import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.network.NetworkError import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -18,35 +14,21 @@ class BaseURLViewModel @Inject constructor(
private val versionDataSource: VersionDataSource, private val versionDataSource: VersionDataSource,
) : ViewModel() { ) : ViewModel() {
private val _screenState = MutableLiveData(BaseURLScreenState()) suspend fun saveBaseUrl(baseURL: String): Result<Unit> {
var currentScreenState: BaseURLScreenState
get() = _screenState.value!!
private set(value) {
_screenState.value = value
}
val screenState: LiveData<BaseURLScreenState>
get() = _screenState
fun saveBaseUrl(baseURL: String) {
Timber.v("saveBaseUrl() called with: baseURL = $baseURL") Timber.v("saveBaseUrl() called with: baseURL = $baseURL")
val hasPrefix = ALLOWED_PREFIXES.any { baseURL.startsWith(it) } val hasPrefix = ALLOWED_PREFIXES.any { baseURL.startsWith(it) }
val url = baseURL.takeIf { hasPrefix } ?: WITH_PREFIX_FORMAT.format(baseURL) 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") 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 // If it returns proper version info then it must be a Mealie
versionDataSource.getVersionInfo(baseURL) 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) baseURLStorage.storeBaseURL(baseURL)
currentScreenState = BaseURLScreenState(null, true) return result.map { }
} }
companion object { 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.databinding.FragmentRecipesBinding
import gq.kirmanak.mealient.extensions.collectWithViewLifecycle import gq.kirmanak.mealient.extensions.collectWithViewLifecycle
import gq.kirmanak.mealient.extensions.refreshRequestFlow import gq.kirmanak.mealient.extensions.refreshRequestFlow
import gq.kirmanak.mealient.ui.auth.AuthenticationState
import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel
import timber.log.Timber import timber.log.Timber
@@ -24,19 +23,6 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
private val viewModel by viewModels<RecipeViewModel>() private val viewModel by viewModels<RecipeViewModel>()
private val authViewModel by activityViewModels<AuthenticationViewModel>() 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState") Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")

View File

@@ -9,15 +9,16 @@
android:id="@+id/authenticationFragment" android:id="@+id/authenticationFragment"
android:name="gq.kirmanak.mealient.ui.auth.AuthenticationFragment" android:name="gq.kirmanak.mealient.ui.auth.AuthenticationFragment"
android:label="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 <fragment
android:id="@+id/recipesFragment" android:id="@+id/recipesFragment"
android:name="gq.kirmanak.mealient.ui.recipes.RecipesFragment" android:name="gq.kirmanak.mealient.ui.recipes.RecipesFragment"
android:label="fragment_recipes" android:label="fragment_recipes"
tools:layout="@layout/fragment_recipes"> tools:layout="@layout/fragment_recipes">
<action
android:id="@+id/action_recipesFragment_to_authenticationFragment"
app:destination="@id/authenticationFragment" />
<action <action
android:id="@+id/action_recipesFragment_to_recipeInfoFragment" android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
app:destination="@id/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() { fun setUp() {
MockKAnnotations.init(this) MockKAnnotations.init(this)
subject = AuthDataSourceImpl(authServiceFactory, NetworkModule.createJson()) subject = AuthDataSourceImpl(authServiceFactory, NetworkModule.createJson())
coEvery { authServiceFactory.provideService(any(), eq(false)) } returns authService coEvery { authServiceFactory.provideService(any()) } returns authService
} }
@Test @Test
@@ -72,7 +72,7 @@ class AuthDataSourceImplTest {
@Test(expected = MalformedUrl::class) @Test(expected = MalformedUrl::class)
fun `when authenticate and provideService throws then MalformedUrl`() = runTest { fun `when authenticate and provideService throws then MalformedUrl`() = runTest {
coEvery { coEvery {
authServiceFactory.provideService(any(), eq(false)) authServiceFactory.provideService(any())
} throws MalformedUrl(RuntimeException()) } throws MalformedUrl(RuntimeException())
callAuthenticate() 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 { coVerifySequence {
authRepo.getAuthHeader() authRepo.getAuthHeader()
authRepo.invalidateAuthHeader(TEST_AUTH_HEADER) authRepo.invalidateAuthHeader()
authRepo.getAuthHeader() authRepo.getAuthHeader()
} }
} }

View File

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

View File

@@ -1,10 +1,8 @@
package gq.kirmanak.mealient.ui.baseurl 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.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionDataSource import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.VersionInfo 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.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.RobolectricTest import gq.kirmanak.mealient.test.RobolectricTest
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
@@ -34,35 +32,6 @@ class BaseURLViewModelTest : RobolectricTest() {
subject = BaseURLViewModel(baseURLStorage, versionDataSource) 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 @Test
fun `when saveBaseUrl and getVersionInfo returns result then saves to storage`() = runTest { fun `when saveBaseUrl and getVersionInfo returns result then saves to storage`() = runTest {
coEvery { coEvery {