diff --git a/app/build.gradle b/app/build.gradle index da9b8eb..7df8a63 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 49dab32..26f6c08 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,27 +15,16 @@ android:roundIcon="@mipmap/ic_launcher" android:supportsRtl="true" tools:ignore="UnusedAttribute" - android:theme="@style/Theme.Mealient"> - - - + android:theme="@style/Theme.Mealient"> + + + - - - - - - - - - - - + + + + \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt b/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt index 41b809d..eaf1669 100644 --- a/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt +++ b/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt @@ -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()) + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt index b1d898d..4e2b660 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt @@ -6,7 +6,7 @@ interface AuthRepo { val isAuthorizedFlow: Flow - 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() } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt new file mode 100644 index 0000000..8486e6a --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt @@ -0,0 +1,20 @@ +package gq.kirmanak.mealient.data.auth + +import kotlinx.coroutines.flow.Flow + +interface AuthStorage { + + val authHeaderFlow: Flow + + 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? +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt index 67e12d9..0fe2951 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt @@ -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") diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt index 2d15301..6b8dbb8 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt @@ -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 - 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 { diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt new file mode 100644 index 0000000..49bc744 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt @@ -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 + 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" + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt index b2fc79a..5eb4bf7 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt @@ -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 } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitBuilder.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitBuilder.kt index fc61647..539405e 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitBuilder.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitBuilder.kt @@ -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() } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt index d112685..7a7ea95 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt @@ -13,28 +13,21 @@ class RetrofitServiceFactory( private val baseURLStorage: BaseURLStorage, ) : ServiceFactory { - private val cache: MutableMap = mutableMapOf() + private val cache: MutableMap = 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 { - 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 { + 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) } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt index 0ab4a78..e46c2a8 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt @@ -2,5 +2,5 @@ package gq.kirmanak.mealient.data.network interface ServiceFactory { - suspend fun provideService(baseUrl: String? = null, needAuth: Boolean = true): T + suspend fun provideService(baseUrl: String? = null): T } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt index ac5d54d..cb87c5f 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt @@ -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 = retrofitBuilder.createServiceFactory(baseURLStorage) + ): ServiceFactory { + 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 } diff --git a/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt index 0e5820a..805535b 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt @@ -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 = retrofitBuilder.createServiceFactory(baseURLStorage) + ): ServiceFactory { + return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage) + } } @Binds diff --git a/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt index 790d812..8b3ad9e 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt @@ -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 = retrofitBuilder.createServiceFactory(baseURLStorage) + ): ServiceFactory { + return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage) + } @Provides @Singleton diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt index 17186e9..7e26a99 100644 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt @@ -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 = callbackFlow { inline fun Fragment.collectWithViewLifecycle( flow: Flow, 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) diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt index 37b65c9..3c8a9eb 100644 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt @@ -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 SharedPreferences.prefsChangeFlow( + valueReader: SharedPreferences.() -> T, +): Flow = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, _ -> + val value = prefs.valueReader() + trySend(value).logErrors("prefsChangeFlow") + } + trySend(valueReader()) + registerOnSharedPreferenceChangeListener(listener) + awaitClose { unregisterOnSharedPreferenceChangeListener(listener) } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt deleted file mode 100644 index e374f6b..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt +++ /dev/null @@ -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?, - 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? - ): 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) - } - } -} diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt deleted file mode 100644 index 3fff004..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt +++ /dev/null @@ -1,19 +0,0 @@ -package gq.kirmanak.mealient.service.auth - -import android.accounts.Account -import kotlinx.coroutines.flow.Flow - -interface AccountManagerInteractor { - - fun getAccounts(): Array - - suspend fun addAccount(email: String, password: String): Account - - suspend fun getAuthToken(account: Account): String - - fun accountUpdatesFlow(): Flow> - - suspend fun removeAccount(account: Account) - - fun invalidateAuthToken(token: String) -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt deleted file mode 100644 index 5e8a5bf..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt +++ /dev/null @@ -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 { - 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> { - 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) - } -} diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountParameters.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountParameters.kt deleted file mode 100644 index 9a6450c..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountParameters.kt +++ /dev/null @@ -1,6 +0,0 @@ -package gq.kirmanak.mealient.service.auth - -data class AccountParameters( - val accountType: String, - val authTokenType: String, -) diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt deleted file mode 100644 index 4fb9fc4..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthExtensions.kt +++ /dev/null @@ -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 AccountManagerFuture.await(): T = withContext(Dispatchers.IO) { result } - -internal fun Bundle.toAccount(): Account = Account(accountName(), accountType()) - -internal fun Bundle.accountType(): String = string(KEY_ACCOUNT_TYPE) { "Account type is null" } - -internal fun Bundle.accountName(): String = string(KEY_ACCOUNT_NAME) { "Account name is null" } - -internal fun Bundle.authToken(): String = string(KEY_AUTHTOKEN) { "Auth token is null" } - -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> = - 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) - } - } diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticationService.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticationService.kt deleted file mode 100644 index e59017d..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticationService.kt +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt deleted file mode 100644 index a22dfea..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AuthenticatorException.kt +++ /dev/null @@ -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; } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountActivity.kt b/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountActivity.kt deleted file mode 100644 index c96b43b..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountActivity.kt +++ /dev/null @@ -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() - - override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { - super.onCreate(savedInstanceState, persistentState) - supportActionBar?.title = getString(R.string.app_name) - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountFragment.kt deleted file mode 100644 index 8509a2c..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountFragment.kt +++ /dev/null @@ -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() - - 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) { - 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 - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountViewModel.kt deleted file mode 100644 index 8e87894..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/addaccount/AddAccountViewModel.kt +++ /dev/null @@ -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") - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt index 820ae22..ef84b24 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt @@ -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() - 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) { diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationState.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationState.kt index 70ec901..45ae5cc 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationState.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationState.kt @@ -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") diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt index 0826a92..542cefb 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt @@ -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 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") } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt index 6c8abce..8edeb96 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt @@ -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) { + 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 -> { diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenState.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenState.kt deleted file mode 100644 index bc6bf65..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenState.kt +++ /dev/null @@ -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, -) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt index 33b932e..e5aac34 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt @@ -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 - get() = _screenState - - fun saveBaseUrl(baseURL: String) { + suspend fun saveBaseUrl(baseURL: String): Result { 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 { 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 { diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt index dab5abd..f7dc93e 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt @@ -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() private val authViewModel by activityViewModels() - 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") diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 4a6c092..8af9d4c 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -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"> + + - diff --git a/app/src/main/res/xml/account_authenticator.xml b/app/src/main/res/xml/account_authenticator.xml deleted file mode 100644 index cdbd336..0000000 --- a/app/src/main/res/xml/account_authenticator.xml +++ /dev/null @@ -1,5 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImplTest.kt index 10a405f..746c11e 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImplTest.kt @@ -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() } diff --git a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt deleted file mode 100644 index 50fac9a..0000000 --- a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt +++ /dev/null @@ -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 -} diff --git a/app/src/test/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptorTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptorTest.kt index ef86aef..e8419dd 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptorTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptorTest.kt @@ -66,7 +66,7 @@ class AuthenticationInterceptorTest { coVerifySequence { authRepo.getAuthHeader() - authRepo.invalidateAuthHeader(TEST_AUTH_HEADER) + authRepo.invalidateAuthHeader() authRepo.getAuthHeader() } } diff --git a/app/src/test/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactoryTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactoryTest.kt index aacd515..2fc42c0 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactoryTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactoryTest.kt @@ -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")) } } } \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModelTest.kt b/app/src/test/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModelTest.kt index 2379b53..d633c9f 100644 --- a/app/src/test/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModelTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModelTest.kt @@ -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 {