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 {