Merge pull request #30 from kirmanak/authenticator

Split URL input and authorization
This commit is contained in:
Kirill Kamakin
2022-04-04 16:47:46 +05:00
committed by GitHub
60 changed files with 999 additions and 491 deletions

View File

@@ -11,23 +11,23 @@ import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class App : Application() { class App : Application() {
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382) // Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
@Inject @Inject
lateinit var flipperPlugins: Set<@JvmSuppressWildcards FlipperPlugin> lateinit var flipperPlugins: Set<@JvmSuppressWildcards FlipperPlugin>
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
Timber.v("onCreate() called") Timber.v("onCreate() called")
setupFlipper() setupFlipper()
} }
private fun setupFlipper() { private fun setupFlipper() {
if (FlipperUtils.shouldEnableFlipper(this)) { if (FlipperUtils.shouldEnableFlipper(this)) {
SoLoader.init(this, false) SoLoader.init(this, false)
val flipperClient = AndroidFlipperClient.getInstance(this) val flipperClient = AndroidFlipperClient.getInstance(this)
for (flipperPlugin in flipperPlugins) flipperClient.addPlugin(flipperPlugin) for (flipperPlugin in flipperPlugins) flipperClient.addPlugin(flipperPlugin)
flipperClient.start() flipperClient.start()
}
} }
}
} }

View File

@@ -9,6 +9,9 @@ import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.databinding.MainActivityBinding import gq.kirmanak.mealient.databinding.MainActivityBinding
import gq.kirmanak.mealient.ui.auth.AuthenticationState
import gq.kirmanak.mealient.ui.auth.AuthenticationState.AUTHORIZED
import gq.kirmanak.mealient.ui.auth.AuthenticationState.UNAUTHORIZED
import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel
import timber.log.Timber import timber.log.Timber
@@ -16,7 +19,8 @@ import timber.log.Timber
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var binding: MainActivityBinding private lateinit var binding: MainActivityBinding
private val authViewModel by viewModels<AuthenticationViewModel>() private val authViewModel by viewModels<AuthenticationViewModel>()
private var isAuthenticated = false private val authenticationState: AuthenticationState
get() = authViewModel.currentAuthenticationState
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -48,32 +52,34 @@ class MainActivity : AppCompatActivity() {
private fun listenToAuthStatuses() { private fun listenToAuthStatuses() {
Timber.v("listenToAuthStatuses() called") Timber.v("listenToAuthStatuses() called")
authViewModel.authenticationStatuses().observe(this) { authViewModel.authenticationState.observe(this, ::onAuthStateUpdate)
changeAuthStatus(it)
}
} }
private fun changeAuthStatus(it: Boolean) { private fun onAuthStateUpdate(authState: AuthenticationState) {
Timber.v("changeAuthStatus() called with: it = $it") Timber.v("onAuthStateUpdate() called with: it = $authState")
if (isAuthenticated == it) return
isAuthenticated = it
invalidateOptionsMenu() invalidateOptionsMenu()
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
Timber.v("onCreateOptionsMenu() called with: menu = $menu") Timber.v("onCreateOptionsMenu() called with: menu = $menu")
menuInflater.inflate(R.menu.main_toolbar, menu) menuInflater.inflate(R.menu.main_toolbar, menu)
menu.findItem(R.id.logout).isVisible = isAuthenticated menu.findItem(R.id.logout).isVisible = authenticationState == AUTHORIZED
menu.findItem(R.id.login).isVisible = authenticationState == UNAUTHORIZED
return true return true
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
Timber.v("onOptionsItemSelected() called with: item = $item") Timber.v("onOptionsItemSelected() called with: item = $item")
val result = if (item.itemId == R.id.logout) { val result = when (item.itemId) {
authViewModel.logout() R.id.logout -> {
true authViewModel.logout()
} else { true
super.onOptionsItemSelected(item) }
R.id.login -> {
authViewModel.login()
true
}
else -> super.onOptionsItemSelected(item)
} }
return result return result
} }

View File

@@ -2,7 +2,7 @@ package gq.kirmanak.mealient.data.auth
interface AuthDataSource { interface AuthDataSource {
/** /**
* Tries to acquire authentication token using the provided credentials on specified server. * Tries to acquire authentication token using the provided credentials
*/ */
suspend fun authenticate(username: String, password: String, baseUrl: String): String suspend fun authenticate(username: String, password: String): String
} }

View File

@@ -3,17 +3,14 @@ package gq.kirmanak.mealient.data.auth
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface AuthRepo { interface AuthRepo {
suspend fun authenticate(username: String, password: String, baseUrl: String)
suspend fun getBaseUrl(): String? val isAuthorizedFlow: Flow<Boolean>
suspend fun requireBaseUrl(): String suspend fun authenticate(username: String, password: String)
suspend fun getAuthHeader(): String? suspend fun getAuthHeader(): String?
suspend fun requireAuthHeader(): String suspend fun requireAuthHeader(): String
fun authenticationStatuses(): Flow<Boolean>
suspend fun logout() suspend fun logout()
} }

View File

@@ -3,13 +3,12 @@ package gq.kirmanak.mealient.data.auth
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface AuthStorage { interface AuthStorage {
suspend fun storeAuthData(authHeader: String, baseUrl: String)
suspend fun getBaseUrl(): String? val authHeaderFlow: Flow<String?>
suspend fun storeAuthData(authHeader: String)
suspend fun getAuthHeader(): String? suspend fun getAuthHeader(): String?
fun authHeaderObservable(): Flow<String?>
suspend fun clearAuthData() suspend fun clearAuthData()
} }

View File

@@ -1,8 +1,8 @@
package gq.kirmanak.mealient.data.auth.impl package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.data.auth.AuthDataSource import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.*
import gq.kirmanak.mealient.data.network.ErrorDetail import gq.kirmanak.mealient.data.network.ErrorDetail
import gq.kirmanak.mealient.data.network.NetworkError.*
import gq.kirmanak.mealient.data.network.ServiceFactory import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.extensions.decodeErrorBodyOrNull import gq.kirmanak.mealient.extensions.decodeErrorBodyOrNull
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -20,13 +20,14 @@ class AuthDataSourceImpl @Inject constructor(
private val json: Json, private val json: Json,
) : AuthDataSource { ) : AuthDataSource {
override suspend fun authenticate( override suspend fun authenticate(username: String, password: String): String {
username: String, Timber.v("authenticate() called with: username = $username, password = $password")
password: String, val authService = try {
baseUrl: String authServiceFactory.provideService()
): String { } catch (e: Exception) {
Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl") Timber.e(e, "authenticate: can't create Retrofit service")
val authService = authServiceFactory.provideService(baseUrl) throw MalformedUrl(e)
}
val response = sendRequest(authService, username, password) val response = sendRequest(authService, username, password)
val accessToken = parseToken(response) val accessToken = parseToken(response)
Timber.v("authenticate() returned: $accessToken") Timber.v("authenticate() returned: $accessToken")

View File

@@ -1,14 +1,10 @@
package gq.kirmanak.mealient.data.auth.impl package gq.kirmanak.mealient.data.auth.impl
import android.net.Uri
import androidx.annotation.VisibleForTesting
import gq.kirmanak.mealient.data.auth.AuthDataSource import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.MalformedUrl
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import okhttp3.HttpUrl.Companion.toHttpUrl
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -19,50 +15,26 @@ class AuthRepoImpl @Inject constructor(
private val storage: AuthStorage, private val storage: AuthStorage,
) : AuthRepo { ) : AuthRepo {
override suspend fun authenticate( override val isAuthorizedFlow: Flow<Boolean>
username: String, get() = storage.authHeaderFlow.map { it != null }
password: String,
baseUrl: String override suspend fun authenticate(username: String, password: String) {
) { Timber.v("authenticate() called with: username = $username, password = $password")
Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl") val accessToken = dataSource.authenticate(username, password)
val url = parseBaseUrl(baseUrl)
val accessToken = dataSource.authenticate(username, password, url)
Timber.d("authenticate result is \"$accessToken\"") Timber.d("authenticate result is \"$accessToken\"")
storage.storeAuthData(AUTH_HEADER_FORMAT.format(accessToken), url) storage.storeAuthData(AUTH_HEADER_FORMAT.format(accessToken))
} }
override suspend fun getBaseUrl(): String? = storage.getBaseUrl()
override suspend fun requireBaseUrl(): String =
checkNotNull(getBaseUrl()) { "Base URL is null when it was required" }
override suspend fun getAuthHeader(): String? = storage.getAuthHeader() override suspend fun getAuthHeader(): String? = storage.getAuthHeader()
override suspend fun requireAuthHeader(): String = override suspend fun requireAuthHeader(): String =
checkNotNull(getAuthHeader()) { "Auth header is null when it was required" } checkNotNull(getAuthHeader()) { "Auth header is null when it was required" }
override fun authenticationStatuses(): Flow<Boolean> {
Timber.v("authenticationStatuses() called")
return storage.authHeaderObservable().map { it != null }
}
override suspend fun logout() { override suspend fun logout() {
Timber.v("logout() called") Timber.v("logout() called")
storage.clearAuthData() storage.clearAuthData()
} }
@VisibleForTesting
fun parseBaseUrl(baseUrl: String): String = try {
val withScheme = Uri.parse(baseUrl).let {
if (it.scheme == null) it.buildUpon().scheme("https").build()
else it
}.toString()
withScheme.toHttpUrl().toString()
} catch (e: Throwable) {
Timber.e(e, "authenticate: can't parse base url $baseUrl")
throw MalformedUrl(e)
}
companion object { companion object {
private const val AUTH_HEADER_FORMAT = "Bearer %s" private const val AUTH_HEADER_FORMAT = "Bearer %s"
} }

View File

@@ -1,5 +1,6 @@
package gq.kirmanak.mealient.data.auth.impl package gq.kirmanak.mealient.data.auth.impl
import androidx.datastore.preferences.core.Preferences
import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.storage.PreferencesStorage import gq.kirmanak.mealient.data.storage.PreferencesStorage
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -12,21 +13,14 @@ class AuthStorageImpl @Inject constructor(
private val preferencesStorage: PreferencesStorage, private val preferencesStorage: PreferencesStorage,
) : AuthStorage { ) : AuthStorage {
private val authHeaderKey by preferencesStorage::authHeaderKey private val authHeaderKey: Preferences.Key<String>
private val baseUrlKey by preferencesStorage::baseUrlKey get() = preferencesStorage.authHeaderKey
override val authHeaderFlow: Flow<String?>
get() = preferencesStorage.valueUpdates(authHeaderKey)
override suspend fun storeAuthData(authHeader: String, baseUrl: String) { override suspend fun storeAuthData(authHeader: String) {
Timber.v("storeAuthData() called with: authHeader = $authHeader, baseUrl = $baseUrl") Timber.v("storeAuthData() called with: authHeader = $authHeader")
preferencesStorage.storeValues( preferencesStorage.storeValues(Pair(authHeaderKey, authHeader))
Pair(authHeaderKey, authHeader),
Pair(baseUrlKey, baseUrl),
)
}
override suspend fun getBaseUrl(): String? {
val baseUrl = preferencesStorage.getValue(baseUrlKey)
Timber.d("getBaseUrl: base url is $baseUrl")
return baseUrl
} }
override suspend fun getAuthHeader(): String? { override suspend fun getAuthHeader(): String? {
@@ -36,13 +30,8 @@ class AuthStorageImpl @Inject constructor(
return token return token
} }
override fun authHeaderObservable(): Flow<String?> {
Timber.v("authHeaderObservable() called")
return preferencesStorage.valueUpdates(authHeaderKey)
}
override suspend fun clearAuthData() { override suspend fun clearAuthData() {
Timber.v("clearAuthData() called") Timber.v("clearAuthData() called")
preferencesStorage.removeValues(authHeaderKey, baseUrlKey) preferencesStorage.removeValues(authHeaderKey)
} }
} }

View File

@@ -1,8 +0,0 @@
package gq.kirmanak.mealient.data.auth.impl
sealed class AuthenticationError(cause: Throwable) : RuntimeException(cause) {
class Unauthorized(cause: Throwable) : AuthenticationError(cause)
class NoServerConnection(cause: Throwable) : AuthenticationError(cause)
class NotMealie(cause: Throwable) : AuthenticationError(cause)
class MalformedUrl(cause: Throwable) : AuthenticationError(cause)
}

View File

@@ -0,0 +1,10 @@
package gq.kirmanak.mealient.data.baseurl
interface BaseURLStorage {
suspend fun getBaseURL(): String?
suspend fun requireBaseURL(): String
suspend fun storeBaseURL(baseURL: String)
}

View File

@@ -0,0 +1,25 @@
package gq.kirmanak.mealient.data.baseurl
import androidx.datastore.preferences.core.Preferences
import gq.kirmanak.mealient.data.storage.PreferencesStorage
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BaseURLStorageImpl @Inject constructor(
private val preferencesStorage: PreferencesStorage,
) : BaseURLStorage {
private val baseUrlKey: Preferences.Key<String>
get() = preferencesStorage.baseUrlKey
override suspend fun getBaseURL(): String? = preferencesStorage.getValue(baseUrlKey)
override suspend fun requireBaseURL(): String = checkNotNull(getBaseURL()) {
"Base URL was null when it was required"
}
override suspend fun storeBaseURL(baseURL: String) {
preferencesStorage.storeValues(Pair(baseUrlKey, baseURL))
}
}

View File

@@ -0,0 +1,9 @@
package gq.kirmanak.mealient.data.baseurl
import gq.kirmanak.mealient.data.network.NetworkError
interface VersionDataSource {
@Throws(NetworkError::class)
suspend fun getVersionInfo(baseUrl: String): VersionInfo
}

View File

@@ -0,0 +1,39 @@
package gq.kirmanak.mealient.data.baseurl
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.extensions.versionInfo
import kotlinx.serialization.SerializationException
import retrofit2.HttpException
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class VersionDataSourceImpl @Inject constructor(
private val serviceFactory: ServiceFactory<VersionService>,
) : VersionDataSource {
override suspend fun getVersionInfo(baseUrl: String): VersionInfo {
Timber.v("getVersionInfo() called with: baseUrl = $baseUrl")
val service = try {
serviceFactory.provideService(baseUrl)
} catch (e: Exception) {
Timber.e(e, "getVersionInfo: can't create service")
throw NetworkError.MalformedUrl(e)
}
val response = try {
service.getVersion()
} catch (e: Exception) {
Timber.e(e, "getVersionInfo: can't request version")
when (e) {
is HttpException, is SerializationException -> throw NetworkError.NotMealie(e)
else -> throw NetworkError.NoServerConnection(e)
}
}
return response.versionInfo()
}
}

View File

@@ -0,0 +1,7 @@
package gq.kirmanak.mealient.data.baseurl
data class VersionInfo(
val production: Boolean,
val version: String,
val demoStatus: Boolean,
)

View File

@@ -0,0 +1,14 @@
package gq.kirmanak.mealient.data.baseurl
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class VersionResponse(
@SerialName("production")
val production: Boolean,
@SerialName("version")
val version: String,
@SerialName("demoStatus")
val demoStatus: Boolean,
)

View File

@@ -0,0 +1,8 @@
package gq.kirmanak.mealient.data.baseurl
import retrofit2.http.GET
interface VersionService {
@GET("api/debug/version")
suspend fun getVersion(): VersionResponse
}

View File

@@ -1,6 +1,11 @@
package gq.kirmanak.mealient.data.disclaimer package gq.kirmanak.mealient.data.disclaimer
import kotlinx.coroutines.flow.Flow
interface DisclaimerStorage { interface DisclaimerStorage {
val isDisclaimerAcceptedFlow: Flow<Boolean>
suspend fun isDisclaimerAccepted(): Boolean suspend fun isDisclaimerAccepted(): Boolean
suspend fun acceptDisclaimer() suspend fun acceptDisclaimer()

View File

@@ -1,6 +1,9 @@
package gq.kirmanak.mealient.data.disclaimer package gq.kirmanak.mealient.data.disclaimer
import androidx.datastore.preferences.core.Preferences
import gq.kirmanak.mealient.data.storage.PreferencesStorage import gq.kirmanak.mealient.data.storage.PreferencesStorage
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -10,7 +13,10 @@ class DisclaimerStorageImpl @Inject constructor(
private val preferencesStorage: PreferencesStorage, private val preferencesStorage: PreferencesStorage,
) : DisclaimerStorage { ) : DisclaimerStorage {
private val isDisclaimerAcceptedKey by preferencesStorage::isDisclaimerAcceptedKey private val isDisclaimerAcceptedKey: Preferences.Key<Boolean>
get() = preferencesStorage.isDisclaimerAcceptedKey
override val isDisclaimerAcceptedFlow: Flow<Boolean>
get() = preferencesStorage.valueUpdates(isDisclaimerAcceptedKey).map { it == true }
override suspend fun isDisclaimerAccepted(): Boolean { override suspend fun isDisclaimerAccepted(): Boolean {
Timber.v("isDisclaimerAccepted() called") Timber.v("isDisclaimerAccepted() called")

View File

@@ -0,0 +1,8 @@
package gq.kirmanak.mealient.data.network
sealed class NetworkError(cause: Throwable) : RuntimeException(cause) {
class Unauthorized(cause: Throwable) : NetworkError(cause)
class NoServerConnection(cause: Throwable) : NetworkError(cause)
class NotMealie(cause: Throwable) : NetworkError(cause)
class MalformedUrl(cause: Throwable) : NetworkError(cause)
}

View File

@@ -1,28 +1,29 @@
package gq.kirmanak.mealient.data.network package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import timber.log.Timber import timber.log.Timber
inline fun <reified T> RetrofitBuilder.createServiceFactory() = inline fun <reified T> RetrofitBuilder.createServiceFactory(baseURLStorage: BaseURLStorage) =
RetrofitServiceFactory(T::class.java, this) RetrofitServiceFactory(T::class.java, this, baseURLStorage)
class RetrofitServiceFactory<T>( class RetrofitServiceFactory<T>(
private val serviceClass: Class<T>, private val serviceClass: Class<T>,
private val retrofitBuilder: RetrofitBuilder, private val retrofitBuilder: RetrofitBuilder,
private val baseURLStorage: BaseURLStorage,
) : ServiceFactory<T> { ) : ServiceFactory<T> {
private val cache: MutableMap<String, T> = mutableMapOf() private val cache: MutableMap<String, T> = mutableMapOf()
@Synchronized override suspend fun provideService(baseUrl: String?): T {
override fun provideService(baseUrl: String): T {
Timber.v("provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}") Timber.v("provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}")
val cached = cache[baseUrl] val url = baseUrl ?: baseURLStorage.requireBaseURL()
return if (cached == null) { return synchronized(cache) { cache[url] ?: createService(url, serviceClass) }
Timber.d("provideService: cache is empty, creating new") }
val new = retrofitBuilder.buildRetrofit(baseUrl).create(serviceClass)
cache[baseUrl] = new private fun createService(url: String, serviceClass: Class<T>): T {
new Timber.v("createService() called with: url = $url, serviceClass = ${serviceClass.simpleName}")
} else { val service = retrofitBuilder.buildRetrofit(url).create(serviceClass)
cached cache[url] = service
} return service
} }
} }

View File

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

View File

@@ -11,13 +11,13 @@ data class RecipeSummaryEntity(
@PrimaryKey @ColumnInfo(name = "remote_id") val remoteId: Long, @PrimaryKey @ColumnInfo(name = "remote_id") val remoteId: Long,
@ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "slug") val slug: String, @ColumnInfo(name = "slug") val slug: String,
@ColumnInfo(name = "image") val image: String, @ColumnInfo(name = "image") val image: String?,
@ColumnInfo(name = "description") val description: String, @ColumnInfo(name = "description") val description: String,
@ColumnInfo(name = "rating") val rating: Int?, @ColumnInfo(name = "rating") val rating: Int?,
@ColumnInfo(name = "date_added") val dateAdded: LocalDate, @ColumnInfo(name = "date_added") val dateAdded: LocalDate,
@ColumnInfo(name = "date_updated") val dateUpdated: LocalDateTime @ColumnInfo(name = "date_updated") val dateUpdated: LocalDateTime
) { ) {
override fun toString(): String { override fun toString(): String {
return "RecipeEntity(remoteId=$remoteId, name='$name')" return "RecipeSummaryEntity(remoteId=$remoteId, name='$name')"
} }
} }

View File

@@ -3,7 +3,7 @@ package gq.kirmanak.mealient.data.recipes.impl
import android.widget.ImageView import android.widget.ImageView
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.recipes.RecipeImageLoader import gq.kirmanak.mealient.data.recipes.RecipeImageLoader
import gq.kirmanak.mealient.ui.ImageLoader import gq.kirmanak.mealient.ui.ImageLoader
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@@ -14,7 +14,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class RecipeImageLoaderImpl @Inject constructor( class RecipeImageLoaderImpl @Inject constructor(
private val imageLoader: ImageLoader, private val imageLoader: ImageLoader,
private val authRepo: AuthRepo private val baseURLStorage: BaseURLStorage,
): RecipeImageLoader { ): RecipeImageLoader {
override suspend fun loadRecipeImage(view: ImageView, slug: String?) { override suspend fun loadRecipeImage(view: ImageView, slug: String?) {
@@ -25,7 +25,7 @@ class RecipeImageLoaderImpl @Inject constructor(
@VisibleForTesting @VisibleForTesting
suspend fun generateImageUrl(slug: String?): String? { suspend fun generateImageUrl(slug: String?): String? {
Timber.v("generateImageUrl() called with: slug = $slug") Timber.v("generateImageUrl() called with: slug = $slug")
val result = authRepo.getBaseUrl() val result = baseURLStorage.getBaseURL()
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?.takeUnless { slug.isNullOrBlank() } ?.takeUnless { slug.isNullOrBlank() }
?.toHttpUrlOrNull() ?.toHttpUrlOrNull()

View File

@@ -23,7 +23,7 @@ class RecipeRepoImpl @Inject constructor(
) : RecipeRepo { ) : RecipeRepo {
override fun createPager(): Pager<Int, RecipeSummaryEntity> { override fun createPager(): Pager<Int, RecipeSummaryEntity> {
Timber.v("createPager() called") Timber.v("createPager() called")
val pagingConfig = PagingConfig(pageSize = 30, enablePlaceholders = true) val pagingConfig = PagingConfig(pageSize = 5, enablePlaceholders = true)
return Pager( return Pager(
config = pagingConfig, config = pagingConfig,
remoteMediator = mediator, remoteMediator = mediator,

View File

@@ -30,8 +30,8 @@ class RecipeDataSourceImpl @Inject constructor(
private suspend fun getRecipeService(): RecipeService { private suspend fun getRecipeService(): RecipeService {
Timber.v("getRecipeService() called") Timber.v("getRecipeService() called")
return recipeServiceFactory.provideService(authRepo.requireBaseUrl()) return recipeServiceFactory.provideService()
} }
private suspend fun getToken(): String = authRepo.requireAuthHeader() private suspend fun getToken(): String? = authRepo.getAuthHeader()
} }

View File

@@ -10,7 +10,7 @@ data class GetRecipeSummaryResponse(
@SerialName("id") val remoteId: Long, @SerialName("id") val remoteId: Long,
@SerialName("name") val name: String, @SerialName("name") val name: String,
@SerialName("slug") val slug: String, @SerialName("slug") val slug: String,
@SerialName("image") val image: String, @SerialName("image") val image: String?,
@SerialName("description") val description: String = "", @SerialName("description") val description: String = "",
@SerialName("recipeCategory") val recipeCategories: List<String>, @SerialName("recipeCategory") val recipeCategories: List<String>,
@SerialName("tags") val tags: List<String>, @SerialName("tags") val tags: List<String>,

View File

@@ -12,6 +12,7 @@ import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl
import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl
import gq.kirmanak.mealient.data.auth.impl.AuthService import gq.kirmanak.mealient.data.auth.impl.AuthService
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.network.RetrofitBuilder import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory import gq.kirmanak.mealient.data.network.createServiceFactory
@@ -21,24 +22,25 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface AuthModule { interface AuthModule {
companion object { companion object {
@Provides @Provides
@Singleton @Singleton
fun provideAuthServiceFactory(retrofitBuilder: RetrofitBuilder): ServiceFactory<AuthService> { fun provideAuthServiceFactory(
return retrofitBuilder.createServiceFactory() retrofitBuilder: RetrofitBuilder,
baseURLStorage: BaseURLStorage,
): ServiceFactory<AuthService> = retrofitBuilder.createServiceFactory(baseURLStorage)
} }
}
@Binds @Binds
@Singleton @Singleton
fun bindAuthDataSource(authDataSourceImpl: AuthDataSourceImpl): AuthDataSource fun bindAuthDataSource(authDataSourceImpl: AuthDataSourceImpl): AuthDataSource
@Binds @Binds
@Singleton @Singleton
fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage
@Binds @Binds
@Singleton @Singleton
fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo
} }

View File

@@ -0,0 +1,35 @@
package gq.kirmanak.mealient.di
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
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 javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface BaseURLModule {
companion object {
@Provides
@Singleton
fun provideVersionServiceFactory(
retrofitBuilder: RetrofitBuilder,
baseURLStorage: BaseURLStorage,
): ServiceFactory<VersionService> = retrofitBuilder.createServiceFactory(baseURLStorage)
}
@Binds
@Singleton
fun bindVersionDataSource(versionDataSourceImpl: VersionDataSourceImpl): VersionDataSource
@Binds
@Singleton
fun bindBaseUrlStorage(baseURLStorageImpl: BaseURLStorageImpl): BaseURLStorage
}

View File

@@ -6,6 +6,7 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.network.RetrofitBuilder import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory import gq.kirmanak.mealient.data.network.createServiceFactory
@@ -44,9 +45,10 @@ interface RecipeModule {
@Provides @Provides
@Singleton @Singleton
fun provideRecipeServiceFactory(retrofitBuilder: RetrofitBuilder): ServiceFactory<RecipeService> { fun provideRecipeServiceFactory(
return retrofitBuilder.createServiceFactory() retrofitBuilder: RetrofitBuilder,
} baseURLStorage: BaseURLStorage,
): ServiceFactory<RecipeService> = retrofitBuilder.createServiceFactory(baseURLStorage)
@Provides @Provides
@Singleton @Singleton

View File

@@ -1,5 +1,7 @@
package gq.kirmanak.mealient.extensions package gq.kirmanak.mealient.extensions
import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.data.baseurl.VersionResponse
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeEntity import gq.kirmanak.mealient.data.recipes.db.entity.RecipeEntity
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeIngredientEntity import gq.kirmanak.mealient.data.recipes.db.entity.RecipeIngredientEntity
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeInstructionEntity import gq.kirmanak.mealient.data.recipes.db.entity.RecipeInstructionEntity
@@ -42,3 +44,5 @@ fun GetRecipeSummaryResponse.recipeEntity() = RecipeSummaryEntity(
dateAdded = dateAdded, dateAdded = dateAdded,
dateUpdated = dateUpdated, dateUpdated = dateUpdated,
) )
fun VersionResponse.versionInfo() = VersionInfo(production, version, demoStatus)

View File

@@ -4,13 +4,16 @@ import android.app.Activity
import android.os.Build import android.os.Build
import android.view.View import android.view.View
import android.view.WindowInsets import android.view.WindowInsets
import android.widget.EditText
import android.widget.TextView import android.widget.TextView
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.textfield.TextInputLayout
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.ChannelResult import kotlinx.coroutines.channels.ChannelResult
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
@@ -18,6 +21,8 @@ import kotlinx.coroutines.channels.onClosed
import kotlinx.coroutines.channels.onFailure import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import timber.log.Timber import timber.log.Timber
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@@ -80,3 +85,28 @@ fun <T> ChannelResult<T>.logErrors(methodName: String): ChannelResult<T> {
onClosed { Timber.e(it, "$methodName: flow has been closed") } onClosed { Timber.e(it, "$methodName: flow has been closed") }
return this return this
} }
fun EditText.checkIfInputIsEmpty(
inputLayout: TextInputLayout,
lifecycleCoroutineScope: LifecycleCoroutineScope,
errorText: () -> String
): String? {
Timber.v("checkIfInputIsEmpty() called with: input = $this, inputLayout = $inputLayout, errorText = $errorText")
val text = text?.toString()
Timber.d("Input text is \"$text\"")
if (text.isNullOrEmpty()) {
inputLayout.error = errorText()
lifecycleCoroutineScope.launchWhenResumed {
waitUntilNotEmpty()
inputLayout.error = null
}
return null
}
return text
}
suspend fun EditText.waitUntilNotEmpty() {
Timber.v("waitUntilNotEmpty() called with: input = $this")
textChangesFlow().filterNotNull().first { it.isNotEmpty() }
Timber.v("waitUntilNotEmpty() returned")
}

View File

@@ -2,43 +2,32 @@ package gq.kirmanak.mealient.ui.auth
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.widget.EditText
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import by.kirich1409.viewbindingdelegate.viewBinding import by.kirich1409.viewbindingdelegate.viewBinding
import com.google.android.material.textfield.TextInputLayout
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.* import gq.kirmanak.mealient.data.network.NetworkError.Unauthorized
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
import gq.kirmanak.mealient.ui.textChangesFlow import gq.kirmanak.mealient.ui.checkIfInputIsEmpty
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import timber.log.Timber import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class AuthenticationFragment : Fragment(R.layout.fragment_authentication) { class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
private val binding by viewBinding(FragmentAuthenticationBinding::bind) private val binding by viewBinding(FragmentAuthenticationBinding::bind)
private val viewModel by viewModels<AuthenticationViewModel>() private val viewModel by activityViewModels<AuthenticationViewModel>()
private val authStatuses by lazy { viewModel.authenticationStatuses() } private val authStatuses: LiveData<AuthenticationState>
private val authStatusObserver = Observer<Boolean> { onAuthStatusChange(it) } get() = viewModel.authenticationState
private fun onAuthStatusChange(isAuthenticated: Boolean) {
Timber.v("onAuthStatusChange() called with: isAuthenticated = $isAuthenticated")
if (isAuthenticated) {
authStatuses.removeObserver(authStatusObserver)
navigateToRecipes()
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState") Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState")
authStatuses.observe(this, authStatusObserver) authStatuses.observe(this, ::onAuthStatusChange)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -49,69 +38,33 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
getString(R.string.app_name) getString(R.string.app_name)
} }
private fun navigateToRecipes() { private fun onAuthStatusChange(isAuthenticated: AuthenticationState) {
Timber.v("navigateToRecipes() called") Timber.v("onAuthStatusChange() called with: isAuthenticated = $isAuthenticated")
findNavController().navigate(AuthenticationFragmentDirections.actionAuthenticationFragmentToRecipesFragment()) if (isAuthenticated == AuthenticationState.AUTHORIZED) {
findNavController().popBackStack()
}
} }
private fun onLoginClicked(): Unit = with(binding) { private fun onLoginClicked(): Unit = with(binding) {
Timber.v("onLoginClicked() called") Timber.v("onLoginClicked() called")
val email: String = checkIfInputIsEmpty(emailInput, emailInputLayout) { val email: String = emailInput.checkIfInputIsEmpty(emailInputLayout, lifecycleScope) {
getString(R.string.fragment_authentication_email_input_empty) getString(R.string.fragment_authentication_email_input_empty)
} ?: return } ?: return
val pass: String = checkIfInputIsEmpty(passwordInput, passwordInputLayout) { val pass: String = passwordInput.checkIfInputIsEmpty(passwordInputLayout, lifecycleScope) {
getString(R.string.fragment_authentication_password_input_empty) getString(R.string.fragment_authentication_password_input_empty)
} ?: return } ?: return
val url: String = checkIfInputIsEmpty(urlInput, urlInputLayout) {
getString(R.string.fragment_authentication_url_input_empty)
} ?: return
button.isClickable = false button.isClickable = false
viewModel.authenticate(email, pass, url).observe(viewLifecycleOwner) { viewModel.authenticate(email, pass).observe(viewLifecycleOwner) {
Timber.d("onLoginClicked: result $it") Timber.d("onLoginClicked: result $it")
passwordInputLayout.error = when (it.exceptionOrNull()) { passwordInputLayout.error = when (it.exceptionOrNull()) {
is Unauthorized -> getString(R.string.fragment_authentication_credentials_incorrect) is Unauthorized -> getString(R.string.fragment_authentication_credentials_incorrect)
else -> null else -> null
} }
urlInputLayout.error = when (val exception = it.exceptionOrNull()) {
is NoServerConnection -> getString(R.string.fragment_authentication_no_connection)
is NotMealie -> getString(R.string.fragment_authentication_unexpected_response)
is MalformedUrl -> {
val cause = exception.cause?.message ?: exception.message
getString(R.string.fragment_authentication_url_invalid, cause)
}
is Unauthorized, null -> null
else -> getString(R.string.fragment_authentication_unknown_error)
}
button.isClickable = true button.isClickable = true
} }
} }
private fun checkIfInputIsEmpty(
input: EditText,
inputLayout: TextInputLayout,
errorText: () -> String
): String? {
Timber.v("checkIfInputIsEmpty() called with: input = $input, inputLayout = $inputLayout, errorText = $errorText")
val text = input.text?.toString()
Timber.d("Input text is \"$text\"")
if (text.isNullOrEmpty()) {
inputLayout.error = errorText()
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
waitUntilNotEmpty(input)
inputLayout.error = null
}
return null
}
return text
}
private suspend fun waitUntilNotEmpty(input: EditText) {
Timber.v("waitUntilNotEmpty() called with: input = $input")
input.textChangesFlow().filterNotNull().first { it.isNotEmpty() }
Timber.v("waitUntilNotEmpty() returned")
}
} }

View File

@@ -0,0 +1,26 @@
package gq.kirmanak.mealient.ui.auth
import timber.log.Timber
enum class AuthenticationState {
AUTHORIZED,
AUTH_REQUESTED,
UNAUTHORIZED;
companion object {
fun determineState(
isLoginRequested: Boolean,
isAuthorized: Boolean,
): AuthenticationState {
Timber.v("determineState() called with: isLoginRequested = $isLoginRequested, isAuthorized = $isAuthorized")
val result = when {
isAuthorized -> AUTHORIZED
isLoginRequested -> AUTH_REQUESTED
else -> UNAUTHORIZED
}
Timber.v("determineState() returned: $result")
return result
}
}
}

View File

@@ -3,7 +3,8 @@ package gq.kirmanak.mealient.ui.auth
import androidx.lifecycle.* import androidx.lifecycle.*
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.recipes.RecipeRepo import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -11,15 +12,22 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AuthenticationViewModel @Inject constructor( class AuthenticationViewModel @Inject constructor(
private val authRepo: AuthRepo, private val authRepo: AuthRepo,
private val recipeRepo: RecipeRepo
) : ViewModel() { ) : ViewModel() {
fun authenticate(username: String, password: String, baseUrl: String): LiveData<Result<Unit>> { private val loginRequestsFlow = MutableStateFlow(false)
Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl") val authenticationState: LiveData<AuthenticationState> = loginRequestsFlow.combine(
flow = authRepo.isAuthorizedFlow,
transform = AuthenticationState::determineState
).asLiveData()
val currentAuthenticationState: AuthenticationState
get() = checkNotNull(authenticationState.value) { "Auth state flow mustn't be null" }
fun authenticate(username: String, password: String): LiveData<Result<Unit>> {
Timber.v("authenticate() called with: username = $username, password = $password")
val result = MutableLiveData<Result<Unit>>() val result = MutableLiveData<Result<Unit>>()
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
authRepo.authenticate(username, password, baseUrl) authRepo.authenticate(username, password)
}.onFailure { }.onFailure {
Timber.e(it, "authenticate: can't authenticate") Timber.e(it, "authenticate: can't authenticate")
result.value = Result.failure(it) result.value = Result.failure(it)
@@ -31,16 +39,16 @@ class AuthenticationViewModel @Inject constructor(
return result return result
} }
fun authenticationStatuses(): LiveData<Boolean> {
Timber.v("authenticationStatuses() called")
return authRepo.authenticationStatuses().asLiveData()
}
fun logout() { fun logout() {
Timber.v("logout() called") Timber.v("logout() called")
viewModelScope.launch { viewModelScope.launch {
loginRequestsFlow.emit(false)
authRepo.logout() authRepo.logout()
recipeRepo.clearLocalData()
} }
} }
fun login() {
Timber.v("login() called")
viewModelScope.launch { loginRequestsFlow.emit(true) }
}
} }

View File

@@ -0,0 +1,55 @@
package gq.kirmanak.mealient.ui.baseurl
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
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.FragmentBaseUrlBinding
import gq.kirmanak.mealient.ui.checkIfInputIsEmpty
import timber.log.Timber
@AndroidEntryPoint
class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
private val binding by viewBinding(FragmentBaseUrlBinding::bind)
private val viewModel by viewModels<BaseURLViewModel>()
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)
}
private fun onProceedClick(view: View) {
Timber.v("onProceedClick() called with: view = $view")
val url = binding.urlInput.checkIfInputIsEmpty(binding.urlInputLayout, lifecycleScope) {
getString(R.string.fragment_baseurl_url_input_empty)
} ?: return
viewModel.saveBaseUrl(url)
}
private fun updateState(baseURLScreenState: BaseURLScreenState) {
Timber.v("updateState() called with: baseURLScreenState = $baseURLScreenState")
if (baseURLScreenState.navigateNext) {
findNavController().navigate(BaseURLFragmentDirections.actionBaseURLFragmentToRecipesFragment())
return
}
binding.urlInputLayout.error = when (val exception = baseURLScreenState.error) {
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 -> {
val cause = exception.cause?.message ?: exception.message
getString(R.string.fragment_base_url_malformed_url, cause)
}
null -> null
else -> getString(R.string.fragment_base_url_unknown_error)
}
}
}

View File

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

View File

@@ -0,0 +1,56 @@
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 timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class BaseURLViewModel @Inject constructor(
private val baseURLStorage: BaseURLStorage,
private val versionDataSource: VersionDataSource,
) : ViewModel() {
private val _screenState = MutableLiveData(BaseURLScreenState())
var currentScreenState: BaseURLScreenState
get() = _screenState.value!!
private set(value) {
_screenState.value = value
}
val screenState: LiveData<BaseURLScreenState>
get() = _screenState
fun saveBaseUrl(baseURL: String) {
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) }
}
private suspend fun checkBaseURL(baseURL: String) {
Timber.v("checkBaseURL() called with: baseURL = $baseURL")
val version = try {
// 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)
}
companion object {
private val ALLOWED_PREFIXES = listOf("http://", "https://")
private const val WITH_PREFIX_FORMAT = "https://%s"
}
}

View File

@@ -20,21 +20,17 @@ class DisclaimerFragment : Fragment(R.layout.fragment_disclaimer) {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState") Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState")
listenToAcceptStatus() viewModel.isAccepted.observe(this, ::onAcceptStateChange)
} }
private fun listenToAcceptStatus() { private fun onAcceptStateChange(isAccepted: Boolean) {
Timber.v("listenToAcceptStatus() called") Timber.v("onAcceptStateChange() called with: isAccepted = $isAccepted")
viewModel.isAccepted.observe(this) { if (isAccepted) navigateNext()
Timber.d("listenToAcceptStatus: new status = $it")
if (it) navigateToAuth()
}
viewModel.checkIsAccepted()
} }
private fun navigateToAuth() { private fun navigateNext() {
Timber.v("navigateToAuth() called") Timber.v("navigateNext() called")
findNavController().navigate(DisclaimerFragmentDirections.actionDisclaimerFragmentToAuthenticationFragment()) findNavController().navigate(DisclaimerFragmentDirections.actionDisclaimerFragmentToBaseURLFragment())
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View File

@@ -1,10 +1,7 @@
package gq.kirmanak.mealient.ui.disclaimer package gq.kirmanak.mealient.ui.disclaimer
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LiveData import androidx.lifecycle.*
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -21,25 +18,15 @@ import javax.inject.Inject
class DisclaimerViewModel @Inject constructor( class DisclaimerViewModel @Inject constructor(
private val disclaimerStorage: DisclaimerStorage private val disclaimerStorage: DisclaimerStorage
) : ViewModel() { ) : ViewModel() {
private val _isAccepted = MutableLiveData(false) val isAccepted: LiveData<Boolean>
val isAccepted: LiveData<Boolean> = _isAccepted get() = disclaimerStorage.isDisclaimerAcceptedFlow.asLiveData()
private val _okayCountDown = MutableLiveData(FULL_COUNT_DOWN_SEC) private val _okayCountDown = MutableLiveData(FULL_COUNT_DOWN_SEC)
val okayCountDown: LiveData<Int> = _okayCountDown val okayCountDown: LiveData<Int> = _okayCountDown
fun checkIsAccepted() {
Timber.v("checkIsAccepted() called")
viewModelScope.launch {
_isAccepted.value = disclaimerStorage.isDisclaimerAccepted()
}
}
fun acceptDisclaimer() { fun acceptDisclaimer() {
Timber.v("acceptDisclaimer() called") Timber.v("acceptDisclaimer() called")
viewModelScope.launch { viewModelScope.launch { disclaimerStorage.acceptDisclaimer() }
disclaimerStorage.acceptDisclaimer()
_isAccepted.value = true
}
} }
fun startCountDown() { fun startCountDown() {

View File

@@ -4,8 +4,8 @@ import android.os.Bundle
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import by.kirich1409.viewbindingdelegate.viewBinding import by.kirich1409.viewbindingdelegate.viewBinding
@@ -13,6 +13,7 @@ import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.FragmentRecipesBinding import gq.kirmanak.mealient.databinding.FragmentRecipesBinding
import gq.kirmanak.mealient.ui.auth.AuthenticationState
import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel
import gq.kirmanak.mealient.ui.refreshesLiveData import gq.kirmanak.mealient.ui.refreshesLiveData
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
@@ -22,15 +23,17 @@ import timber.log.Timber
class RecipesFragment : Fragment(R.layout.fragment_recipes) { class RecipesFragment : Fragment(R.layout.fragment_recipes) {
private val binding by viewBinding(FragmentRecipesBinding::bind) private val binding by viewBinding(FragmentRecipesBinding::bind)
private val viewModel by viewModels<RecipeViewModel>() private val viewModel by viewModels<RecipeViewModel>()
private val authViewModel by activityViewModels<AuthenticationViewModel>()
private val authViewModel by viewModels<AuthenticationViewModel>() override fun onCreate(savedInstanceState: Bundle?) {
private val authStatuses by lazy { authViewModel.authenticationStatuses() } super.onCreate(savedInstanceState)
private val authStatusObserver = Observer<Boolean> { onAuthStatusChange(it) } authViewModel.authenticationState.observe(this, ::onAuthStateChange)
private fun onAuthStatusChange(isAuthenticated: Boolean) { }
Timber.v("onAuthStatusChange() called with: isAuthenticated = $isAuthenticated")
if (!isAuthenticated) { private fun onAuthStateChange(authenticationState: AuthenticationState) {
authStatuses.removeObserver(authStatusObserver) Timber.v("onAuthStateChange() called with: authenticationState = $authenticationState")
navigateToAuthFragment() if (authenticationState == AuthenticationState.AUTH_REQUESTED) {
findNavController().navigate(RecipesFragmentDirections.actionRecipesFragmentToAuthenticationFragment())
} }
} }
@@ -38,7 +41,6 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState") Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
setupRecipeAdapter() setupRecipeAdapter()
authStatuses.observe(viewLifecycleOwner, authStatusObserver)
(requireActivity() as? AppCompatActivity)?.supportActionBar?.title = null (requireActivity() as? AppCompatActivity)?.supportActionBar?.title = null
} }
@@ -52,11 +54,6 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
) )
} }
private fun navigateToAuthFragment() {
Timber.v("navigateToAuthFragment() called")
findNavController().navigate(RecipesFragmentDirections.actionRecipesFragmentToAuthenticationFragment())
}
private fun setupRecipeAdapter() { private fun setupRecipeAdapter() {
Timber.v("setupRecipeAdapter() called") Timber.v("setupRecipeAdapter() called")
binding.recipes.adapter = viewModel.adapter binding.recipes.adapter = viewModel.adapter

View File

@@ -19,58 +19,58 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class RecipeInfoFragment : BottomSheetDialogFragment() { class RecipeInfoFragment : BottomSheetDialogFragment() {
private val binding by viewBinding(FragmentRecipeInfoBinding::bind) private val binding by viewBinding(FragmentRecipeInfoBinding::bind)
private val arguments by navArgs<RecipeInfoFragmentArgs>() private val arguments by navArgs<RecipeInfoFragmentArgs>()
private val viewModel by viewModels<RecipeInfoViewModel>() private val viewModel by viewModels<RecipeInfoViewModel>()
@Inject @Inject
lateinit var ingredientsAdapter: RecipeIngredientsAdapter lateinit var ingredientsAdapter: RecipeIngredientsAdapter
@Inject @Inject
lateinit var instructionsAdapter: RecipeInstructionsAdapter lateinit var instructionsAdapter: RecipeInstructionsAdapter
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
Timber.v("onCreateView() called") Timber.v("onCreateView() called")
return FragmentRecipeInfoBinding.inflate(inflater, container, false).root return FragmentRecipeInfoBinding.inflate(inflater, container, false).root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called")
binding.ingredientsList.adapter = ingredientsAdapter
binding.instructionsList.adapter = instructionsAdapter
viewModel.loadRecipeImage(binding.image, arguments.recipeSlug)
viewModel.loadRecipeInfo(arguments.recipeId, arguments.recipeSlug)
viewModel.recipeInfo.observe(viewLifecycleOwner) {
Timber.d("onViewCreated: full info $it")
binding.title.text = it.recipeSummaryEntity.name
binding.description.text = it.recipeSummaryEntity.description
} }
viewModel.listsVisibility.observe(viewLifecycleOwner) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
Timber.d("onViewCreated: lists visibility $it") super.onViewCreated(view, savedInstanceState)
binding.ingredientsHolder.isVisible = it.areIngredientsVisible Timber.v("onViewCreated() called")
binding.instructionsGroup.isVisible = it.areInstructionsVisible
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = binding.ingredientsList.adapter = ingredientsAdapter
BottomSheetDialog(requireContext(), R.style.NoShapeBottomSheetDialog) binding.instructionsList.adapter = instructionsAdapter
override fun onDestroyView() { viewModel.loadRecipeImage(binding.image, arguments.recipeSlug)
super.onDestroyView() viewModel.loadRecipeInfo(arguments.recipeId, arguments.recipeSlug)
Timber.v("onDestroyView() called")
// Prevent RV leaking through mObservers list in adapter viewModel.recipeInfo.observe(viewLifecycleOwner) {
with(binding) { Timber.d("onViewCreated: full info $it")
ingredientsList.adapter = null binding.title.text = it.recipeSummaryEntity.name
instructionsList.adapter = null binding.description.text = it.recipeSummaryEntity.description
}
viewModel.listsVisibility.observe(viewLifecycleOwner) {
Timber.d("onViewCreated: lists visibility $it")
binding.ingredientsHolder.isVisible = it.areIngredientsVisible
binding.instructionsGroup.isVisible = it.areInstructionsVisible
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
BottomSheetDialog(requireContext(), R.style.NoShapeBottomSheetDialog)
override fun onDestroyView() {
super.onDestroyView()
Timber.v("onDestroyView() called")
// Prevent RV leaking through mObservers list in adapter
with(binding) {
ingredientsList.adapter = null
instructionsList.adapter = null
}
} }
}
} }

View File

@@ -24,9 +24,11 @@ constructor(
) : ViewModel() { ) : ViewModel() {
private val _recipeInfo = MutableLiveData<FullRecipeInfo>() private val _recipeInfo = MutableLiveData<FullRecipeInfo>()
val recipeInfo: LiveData<FullRecipeInfo> by ::_recipeInfo val recipeInfo: LiveData<FullRecipeInfo>
get() = _recipeInfo
private val _listsVisibility = MutableLiveData(RecipeInfoListsVisibility()) private val _listsVisibility = MutableLiveData(RecipeInfoListsVisibility())
val listsVisibility: LiveData<RecipeInfoListsVisibility> by ::_listsVisibility val listsVisibility: LiveData<RecipeInfoListsVisibility>
get() = _listsVisibility
fun loadRecipeImage(view: ImageView, recipeSlug: String) { fun loadRecipeImage(view: ImageView, recipeSlug: String) {
Timber.v("loadRecipeImage() called with: view = $view, recipeSlug = $recipeSlug") Timber.v("loadRecipeImage() called with: view = $view, recipeSlug = $recipeSlug")

View File

@@ -5,6 +5,7 @@ import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.NavDirections
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
@@ -19,10 +20,12 @@ class SplashFragment : Fragment(R.layout.fragment_splash) {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState") Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState")
viewModel.nextDestination.observe(this) { viewModel.nextDestination.observe(this, ::onNextDestination)
Timber.d("onCreate: next destination $it") }
findNavController().navigate(it)
} private fun onNextDestination(navDirections: NavDirections) {
Timber.v("onNextDestination() called with: navDirections = $navDirections")
findNavController().navigate(navDirections)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View File

@@ -6,17 +6,16 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.NavDirections import androidx.navigation.NavDirections
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SplashViewModel @Inject constructor( class SplashViewModel @Inject constructor(
private val authRepo: AuthRepo, private val disclaimerStorage: DisclaimerStorage,
private val disclaimerStorage: DisclaimerStorage private val baseURLStorage: BaseURLStorage,
) : ViewModel() { ) : ViewModel() {
private val _nextDestination = MutableLiveData<NavDirections>() private val _nextDestination = MutableLiveData<NavDirections>()
val nextDestination: LiveData<NavDirections> = _nextDestination val nextDestination: LiveData<NavDirections> = _nextDestination
@@ -26,8 +25,8 @@ class SplashViewModel @Inject constructor(
delay(1000) delay(1000)
_nextDestination.value = if (!disclaimerStorage.isDisclaimerAccepted()) _nextDestination.value = if (!disclaimerStorage.isDisclaimerAccepted())
SplashFragmentDirections.actionSplashFragmentToDisclaimerFragment() SplashFragmentDirections.actionSplashFragmentToDisclaimerFragment()
else if (!authRepo.authenticationStatuses().first()) else if (baseURLStorage.getBaseURL() == null)
SplashFragmentDirections.actionSplashFragmentToAuthenticationFragment() SplashFragmentDirections.actionSplashFragmentToBaseURLFragment()
else else
SplashFragmentDirections.actionSplashFragmentToRecipesFragment() SplashFragmentDirections.actionSplashFragmentToRecipesFragment()
} }

View File

@@ -25,44 +25,28 @@
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:id="@+id/password_input_layout" android:id="@+id/password_input_layout"
style="@style/SmallMarginTextInputLayoutStyle" style="@style/SmallMarginTextInputLayoutStyle"
android:hint="@string/fragment_authentication_input_hint_password" android:hint="@string/fragment_authentication_input_hint_password"
app:layout_constraintBottom_toTopOf="@+id/url_input_layout" app:layout_constraintBottom_toTopOf="@+id/button"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:endIconMode="password_toggle" app:endIconMode="password_toggle"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/email_input_layout"> app:layout_constraintTop_toBottomOf="@+id/email_input_layout">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/password_input" android:id="@+id/password_input"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="textPassword" /> android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout <Button
android:id="@+id/url_input_layout" android:id="@+id/button"
style="@style/SmallMarginTextInputLayoutStyle" android:text="@string/fragment_authentication_button_login"
android:hint="@string/fragment_authentication_input_hint_url" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintBottom_toTopOf="@+id/button" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintStart_toStartOf="parent" style="@style/SmallMarginButton"
app:layout_constraintTop_toBottomOf="@+id/password_input_layout"> app:layout_constraintTop_toBottomOf="@+id/password_input_layout" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/url_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/button"
android:text="@string/fragment_authentication_button_login"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
style="@style/SmallMarginButton"
app:layout_constraintTop_toBottomOf="@+id/url_input_layout" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.baseurl.BaseURLFragment">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/url_input_layout"
style="@style/SmallMarginTextInputLayoutStyle"
android:hint="@string/fragment_authentication_input_hint_url"
app:layout_constraintBottom_toTopOf="@+id/button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/url_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/button"
style="@style/SmallMarginButton"
android:text="@string/fragment_base_url_save"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/url_input_layout" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,11 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android" <menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<item <item
android:id="@+id/logout" android:id="@+id/login"
android:contentDescription="@string/menu_main_toolbar_content_description_logout" android:contentDescription="@string/menu_main_toolbar_content_description_login"
android:title="@string/menu_main_toolbar_logout" android:title="@string/menu_main_toolbar_login"
app:showAsAction="never" /> app:showAsAction="never" />
<item
android:id="@+id/logout"
android:contentDescription="@string/menu_main_toolbar_content_description_logout"
android:title="@string/menu_main_toolbar_logout"
app:showAsAction="never" />
</menu> </menu>

View File

@@ -9,13 +9,7 @@
android:id="@+id/authenticationFragment" android:id="@+id/authenticationFragment"
android:name="gq.kirmanak.mealient.ui.auth.AuthenticationFragment" android:name="gq.kirmanak.mealient.ui.auth.AuthenticationFragment"
android:label="AuthenticationFragment" android:label="AuthenticationFragment"
tools:layout="@layout/fragment_authentication"> tools:layout="@layout/fragment_authentication" />
<action
android:id="@+id/action_authenticationFragment_to_recipesFragment"
app:destination="@id/recipesFragment"
app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" />
</fragment>
<fragment <fragment
android:id="@+id/recipesFragment" android:id="@+id/recipesFragment"
android:name="gq.kirmanak.mealient.ui.recipes.RecipesFragment" android:name="gq.kirmanak.mealient.ui.recipes.RecipesFragment"
@@ -23,9 +17,7 @@
tools:layout="@layout/fragment_recipes"> tools:layout="@layout/fragment_recipes">
<action <action
android:id="@+id/action_recipesFragment_to_authenticationFragment" android:id="@+id/action_recipesFragment_to_authenticationFragment"
app:destination="@id/authenticationFragment" app:destination="@id/authenticationFragment" />
app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" />
<action <action
android:id="@+id/action_recipesFragment_to_recipeInfoFragment" android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
app:destination="@id/recipeInfoFragment" /> app:destination="@id/recipeInfoFragment" />
@@ -48,8 +40,8 @@
android:label="DisclaimerFragment" android:label="DisclaimerFragment"
tools:layout="@layout/fragment_disclaimer"> tools:layout="@layout/fragment_disclaimer">
<action <action
android:id="@+id/action_disclaimerFragment_to_authenticationFragment" android:id="@+id/action_disclaimerFragment_to_baseURLFragment"
app:destination="@id/authenticationFragment" app:destination="@id/baseURLFragment"
app:popUpTo="@id/nav_graph" app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" /> app:popUpToInclusive="true" />
</fragment> </fragment>
@@ -58,11 +50,6 @@
android:name="gq.kirmanak.mealient.ui.splash.SplashFragment" android:name="gq.kirmanak.mealient.ui.splash.SplashFragment"
android:label="fragment_splash" android:label="fragment_splash"
tools:layout="@layout/fragment_splash"> tools:layout="@layout/fragment_splash">
<action
android:id="@+id/action_splashFragment_to_authenticationFragment"
app:destination="@id/authenticationFragment"
app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" />
<action <action
android:id="@+id/action_splashFragment_to_disclaimerFragment" android:id="@+id/action_splashFragment_to_disclaimerFragment"
app:destination="@id/disclaimerFragment" app:destination="@id/disclaimerFragment"
@@ -73,5 +60,19 @@
app:destination="@id/recipesFragment" app:destination="@id/recipesFragment"
app:popUpTo="@id/nav_graph" app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" /> app:popUpToInclusive="true" />
<action
android:id="@+id/action_splashFragment_to_baseURLFragment"
app:destination="@id/baseURLFragment"
app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/baseURLFragment"
android:name="gq.kirmanak.mealient.ui.baseurl.BaseURLFragment"
android:label="fragment_base_url"
tools:layout="@layout/fragment_base_url">
<action
android:id="@+id/action_baseURLFragment_to_recipesFragment"
app:destination="@id/recipesFragment" />
</fragment> </fragment>
</navigation> </navigation>

View File

@@ -14,10 +14,12 @@
<string name="fragment_disclaimer_main_text">Этот проект разрабатывается независимо от основного проекта Meale. Он не связан с разработчиками Mealie. О любых проблемах следует писать в репозиторий Mealient, НЕ в репозиторий Mealie.</string> <string name="fragment_disclaimer_main_text">Этот проект разрабатывается независимо от основного проекта Meale. Он не связан с разработчиками Mealie. О любых проблемах следует писать в репозиторий Mealient, НЕ в репозиторий Mealie.</string>
<string name="fragment_authentication_email_input_empty">E-mail не может быть пустым</string> <string name="fragment_authentication_email_input_empty">E-mail не может быть пустым</string>
<string name="fragment_authentication_password_input_empty">Пароль не может быть пустым</string> <string name="fragment_authentication_password_input_empty">Пароль не может быть пустым</string>
<string name="fragment_authentication_url_input_empty">URL не может быть пустым</string> <string name="fragment_baseurl_url_input_empty">URL не может быть пустым</string>
<string name="fragment_authentication_credentials_incorrect">E-mail или пароль не подходит.</string> <string name="fragment_authentication_credentials_incorrect">E-mail или пароль не подходит.</string>
<string name="fragment_authentication_no_connection">Ошибка подключения, проверьте адрес.</string> <string name="fragment_base_url_no_connection">Ошибка подключения, проверьте адрес.</string>
<string name="fragment_authentication_unexpected_response">Неожиданный ответ. Это Mealie?</string> <string name="fragment_base_url_unexpected_response">Неожиданный ответ. Это Mealie?</string>
<string name="fragment_authentication_unknown_error">Что-то пошло не так, попробуйте еще раз.</string> <string name="fragment_authentication_unknown_error">Что-то пошло не так, попробуйте еще раз.</string>
<string name="fragment_authentication_url_invalid">Проверьте формат URL: %s</string> <string name="fragment_base_url_malformed_url">Проверьте формат URL: %s</string>
<string name="fragment_base_url_save">Продолжить</string>
<string name="menu_main_toolbar_login">Войти</string>
</resources> </resources>

View File

@@ -16,10 +16,14 @@
<string name="view_holder_recipe_instructions_step">Step: %d</string> <string name="view_holder_recipe_instructions_step">Step: %d</string>
<string name="fragment_authentication_email_input_empty">E-mail can\'t be empty</string> <string name="fragment_authentication_email_input_empty">E-mail can\'t be empty</string>
<string name="fragment_authentication_password_input_empty">Password can\'t be empty</string> <string name="fragment_authentication_password_input_empty">Password can\'t be empty</string>
<string name="fragment_authentication_url_input_empty">URL can\'t be empty</string> <string name="fragment_baseurl_url_input_empty">URL can\'t be empty</string>
<string name="fragment_authentication_credentials_incorrect">E-mail or password is incorrect.</string> <string name="fragment_authentication_credentials_incorrect">E-mail or password is incorrect.</string>
<string name="fragment_authentication_no_connection">Can\'t connect, check address.</string> <string name="fragment_base_url_no_connection">Can\'t connect, check address.</string>
<string name="fragment_authentication_unexpected_response">Unexpected response. Is it Mealie?</string> <string name="fragment_base_url_unexpected_response">Unexpected response. Is it Mealie?</string>
<string name="fragment_authentication_unknown_error">Something went wrong, please try again.</string> <string name="fragment_authentication_unknown_error">Something went wrong, please try again.</string>
<string name="fragment_authentication_url_invalid">Check URL format: %s</string> <string name="fragment_base_url_malformed_url">Check URL format: %s</string>
<string name="fragment_base_url_save">Proceed</string>
<string name="fragment_base_url_unknown_error" translatable="false">@string/fragment_authentication_unknown_error</string>
<string name="menu_main_toolbar_content_description_login" translatable="false">@string/menu_main_toolbar_login</string>
<string name="menu_main_toolbar_login">Login</string>
</resources> </resources>

View File

@@ -1,17 +1,15 @@
package gq.kirmanak.mealient.data.auth.impl package gq.kirmanak.mealient.data.auth.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.* import gq.kirmanak.mealient.data.network.NetworkError.*
import gq.kirmanak.mealient.data.network.ServiceFactory import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.di.NetworkModule import gq.kirmanak.mealient.di.NetworkModule
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
import gq.kirmanak.mealient.test.toJsonResponseBody import gq.kirmanak.mealient.test.toJsonResponseBody
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -34,6 +32,7 @@ class AuthDataSourceImplTest {
fun setUp() { fun setUp() {
MockKAnnotations.init(this) MockKAnnotations.init(this)
subject = AuthDataSourceImpl(authServiceFactory, NetworkModule.createJson()) subject = AuthDataSourceImpl(authServiceFactory, NetworkModule.createJson())
coEvery { authServiceFactory.provideService() } returns authService
} }
@Test @Test
@@ -66,21 +65,21 @@ class AuthDataSourceImplTest {
@Test(expected = NoServerConnection::class) @Test(expected = NoServerConnection::class)
fun `when authenticate and getToken throws then throws NoServerConnection`() = runTest { fun `when authenticate and getToken throws then throws NoServerConnection`() = runTest {
setUpAuthServiceFactory()
coEvery { authService.getToken(any(), any()) } throws IOException("Server not found") coEvery { authService.getToken(any(), any()) } throws IOException("Server not found")
callAuthenticate() callAuthenticate()
} }
@Test(expected = MalformedUrl::class)
fun `when authenticate and provideService throws then MalformedUrl`() = runTest {
coEvery { authServiceFactory.provideService() } throws RuntimeException()
callAuthenticate()
}
private suspend fun authenticate(response: Response<GetTokenResponse>): String { private suspend fun authenticate(response: Response<GetTokenResponse>): String {
setUpAuthServiceFactory()
coEvery { authService.getToken(eq(TEST_USERNAME), eq(TEST_PASSWORD)) } returns response coEvery { authService.getToken(eq(TEST_USERNAME), eq(TEST_PASSWORD)) } returns response
return callAuthenticate() return callAuthenticate()
} }
private suspend fun callAuthenticate() = private suspend fun callAuthenticate() = subject.authenticate(TEST_USERNAME, TEST_PASSWORD)
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, TEST_BASE_URL)
private fun setUpAuthServiceFactory() {
every { authServiceFactory.provideService(eq(TEST_BASE_URL)) } returns authService
}
} }

View File

@@ -3,10 +3,8 @@ package gq.kirmanak.mealient.data.auth.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthDataSource import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.MalformedUrl import gq.kirmanak.mealient.data.network.NetworkError.Unauthorized
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.Unauthorized
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
@@ -41,22 +39,22 @@ class AuthRepoImplTest : RobolectricTest() {
@Test @Test
fun `when not authenticated then first auth status is false`() = runTest { fun `when not authenticated then first auth status is false`() = runTest {
coEvery { storage.authHeaderObservable() } returns flowOf(null) coEvery { storage.authHeaderFlow } returns flowOf(null)
assertThat(subject.authenticationStatuses().first()).isFalse() assertThat(subject.isAuthorizedFlow.first()).isFalse()
} }
@Test @Test
fun `when authenticated then first auth status is true`() = runTest { fun `when authenticated then first auth status is true`() = runTest {
coEvery { storage.authHeaderObservable() } returns flowOf(TEST_AUTH_HEADER) coEvery { storage.authHeaderFlow } returns flowOf(TEST_AUTH_HEADER)
assertThat(subject.authenticationStatuses().first()).isTrue() assertThat(subject.isAuthorizedFlow.first()).isTrue()
} }
@Test(expected = Unauthorized::class) @Test(expected = Unauthorized::class)
fun `when authentication fails then authenticate throws`() = runTest { fun `when authentication fails then authenticate throws`() = runTest {
coEvery { coEvery {
dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD), eq(TEST_BASE_URL)) dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD))
} throws Unauthorized(RuntimeException()) } throws Unauthorized(RuntimeException())
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, TEST_BASE_URL) subject.authenticate(TEST_USERNAME, TEST_PASSWORD)
} }
@Test @Test
@@ -65,44 +63,11 @@ class AuthRepoImplTest : RobolectricTest() {
assertThat(subject.getAuthHeader()).isEqualTo(TEST_AUTH_HEADER) assertThat(subject.getAuthHeader()).isEqualTo(TEST_AUTH_HEADER)
} }
@Test
fun `when authenticated then getBaseUrl returns url`() = runTest {
coEvery { storage.getBaseUrl() } returns TEST_BASE_URL
assertThat(subject.getBaseUrl()).isEqualTo(TEST_BASE_URL)
}
@Test(expected = MalformedUrl::class)
fun `when baseUrl has ftp scheme then throws`() {
subject.parseBaseUrl("ftp://test")
}
@Test
fun `when baseUrl scheme has one slash then corrects`() {
assertThat(subject.parseBaseUrl("https:/test")).isEqualTo("https://test/")
}
@Test
fun `when baseUrl is single word then appends scheme and slash`() {
assertThat(subject.parseBaseUrl("test")).isEqualTo("https://test/")
}
@Test
fun `when baseUrl is host appends scheme and slash`() {
assertThat(subject.parseBaseUrl("google.com")).isEqualTo("https://google.com/")
}
@Test
fun `when baseUrl is correct then doesn't change`() {
assertThat(subject.parseBaseUrl("https://google.com/")).isEqualTo("https://google.com/")
}
@Test @Test
fun `when authenticated successfully then stores token and url`() = runTest { fun `when authenticated successfully then stores token and url`() = runTest {
coEvery { coEvery { dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) } returns TEST_TOKEN
dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD), eq(TEST_BASE_URL)) subject.authenticate(TEST_USERNAME, TEST_PASSWORD)
} returns TEST_TOKEN coVerify { storage.storeAuthData(TEST_AUTH_HEADER) }
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, TEST_BASE_URL)
coVerify { storage.storeAuthData(TEST_AUTH_HEADER, TEST_BASE_URL) }
} }
@Test @Test

View File

@@ -3,7 +3,6 @@ package gq.kirmanak.mealient.data.auth.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_URL
import gq.kirmanak.mealient.test.HiltRobolectricTest import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -20,18 +19,12 @@ class AuthStorageImplTest : HiltRobolectricTest() {
@Test @Test
fun `when storing auth data then doesn't throw`() = runTest { fun `when storing auth data then doesn't throw`() = runTest {
subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL) subject.storeAuthData(TEST_AUTH_HEADER)
}
@Test
fun `when reading url after storing data then returns url`() = runTest {
subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL)
assertThat(subject.getBaseUrl()).isEqualTo(TEST_URL)
} }
@Test @Test
fun `when reading token after storing data then returns token`() = runTest { fun `when reading token after storing data then returns token`() = runTest {
subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL) subject.storeAuthData(TEST_AUTH_HEADER)
assertThat(subject.getAuthHeader()).isEqualTo(TEST_AUTH_HEADER) assertThat(subject.getAuthHeader()).isEqualTo(TEST_AUTH_HEADER)
} }
@@ -40,40 +33,28 @@ class AuthStorageImplTest : HiltRobolectricTest() {
assertThat(subject.getAuthHeader()).isNull() assertThat(subject.getAuthHeader()).isNull()
} }
@Test
fun `when reading url without storing data then returns null`() = runTest {
assertThat(subject.getBaseUrl()).isNull()
}
@Test @Test
fun `when didn't store auth data then first token is null`() = runTest { fun `when didn't store auth data then first token is null`() = runTest {
assertThat(subject.authHeaderObservable().first()).isNull() assertThat(subject.authHeaderFlow.first()).isNull()
} }
@Test @Test
fun `when stored auth data then first token is correct`() = runTest { fun `when stored auth data then first token is correct`() = runTest {
subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL) subject.storeAuthData(TEST_AUTH_HEADER)
assertThat(subject.authHeaderObservable().first()).isEqualTo(TEST_AUTH_HEADER) assertThat(subject.authHeaderFlow.first()).isEqualTo(TEST_AUTH_HEADER)
} }
@Test @Test
fun `when clearAuthData then first token is null`() = runTest { fun `when clearAuthData then first token is null`() = runTest {
subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL) subject.storeAuthData(TEST_AUTH_HEADER)
subject.clearAuthData() subject.clearAuthData()
assertThat(subject.authHeaderObservable().first()).isNull() assertThat(subject.authHeaderFlow.first()).isNull()
} }
@Test @Test
fun `when clearAuthData then getToken returns null`() = runTest { fun `when clearAuthData then getToken returns null`() = runTest {
subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL) subject.storeAuthData(TEST_AUTH_HEADER)
subject.clearAuthData() subject.clearAuthData()
assertThat(subject.getAuthHeader()).isNull() assertThat(subject.getAuthHeader()).isNull()
} }
@Test
fun `when clearAuthData then getBaseUrl returns null`() = runTest {
subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL)
subject.clearAuthData()
assertThat(subject.getBaseUrl()).isNull()
}
} }

View File

@@ -0,0 +1,65 @@
package gq.kirmanak.mealient.data.baseurl
import androidx.datastore.preferences.core.stringPreferencesKey
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.storage.PreferencesStorage
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class BaseURLStorageImplTest {
@MockK(relaxUnitFun = true)
lateinit var preferencesStorage: PreferencesStorage
lateinit var subject: BaseURLStorage
private val baseUrlKey = stringPreferencesKey("baseUrlKey")
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = BaseURLStorageImpl(preferencesStorage)
every { preferencesStorage.baseUrlKey } returns baseUrlKey
}
@Test
fun `when getBaseURL and preferences storage empty then null`() = runTest {
coEvery { preferencesStorage.getValue(eq(baseUrlKey)) } returns null
assertThat(subject.getBaseURL()).isNull()
}
@Test(expected = IllegalStateException::class)
fun `when requireBaseURL and preferences storage empty then IllegalStateException`() = runTest {
coEvery { preferencesStorage.getValue(eq(baseUrlKey)) } returns null
subject.requireBaseURL()
}
@Test
fun `when getBaseUrl and preferences storage has value then value`() = runTest {
coEvery { preferencesStorage.getValue(eq(baseUrlKey)) } returns "baseUrl"
assertThat(subject.getBaseURL()).isEqualTo("baseUrl")
}
@Test
fun `when requireBaseURL and preferences storage has value then value`() = runTest {
coEvery { preferencesStorage.getValue(eq(baseUrlKey)) } returns "baseUrl"
assertThat(subject.requireBaseURL()).isEqualTo("baseUrl")
}
@Test
fun `when storeBaseURL then calls preferences storage`() = runTest {
subject.storeBaseURL("baseUrl")
coVerify {
preferencesStorage.baseUrlKey
preferencesStorage.storeValues(eq(Pair(baseUrlKey, "baseUrl")))
}
}
}

View File

@@ -0,0 +1,71 @@
package gq.kirmanak.mealient.data.baseurl
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.toJsonResponseBody
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.SerializationException
import okio.IOException
import org.junit.Before
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class VersionDataSourceImplTest {
@MockK
lateinit var versionService: VersionService
@MockK
lateinit var versionServiceFactory: ServiceFactory<VersionService>
lateinit var subject: VersionDataSource
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = VersionDataSourceImpl(versionServiceFactory)
coEvery { versionServiceFactory.provideService(eq(TEST_BASE_URL)) } returns versionService
}
@Test(expected = NetworkError.MalformedUrl::class)
fun `when getVersionInfo and provideService throws then MalformedUrl`() = runTest {
coEvery { versionServiceFactory.provideService(eq(TEST_BASE_URL)) } throws RuntimeException()
subject.getVersionInfo(TEST_BASE_URL)
}
@Test(expected = NetworkError.NotMealie::class)
fun `when getVersionInfo and getVersion throws HttpException then NotMealie`() = runTest {
val error = HttpException(Response.error<VersionResponse>(404, "".toJsonResponseBody()))
coEvery { versionService.getVersion() } throws error
subject.getVersionInfo(TEST_BASE_URL)
}
@Test(expected = NetworkError.NotMealie::class)
fun `when getVersionInfo and getVersion throws SerializationException then NotMealie`() =
runTest {
coEvery { versionService.getVersion() } throws SerializationException()
subject.getVersionInfo(TEST_BASE_URL)
}
@Test(expected = NetworkError.NoServerConnection::class)
fun `when getVersionInfo and getVersion throws IOException then NoServerConnection`() =
runTest {
coEvery { versionService.getVersion() } throws IOException()
subject.getVersionInfo(TEST_BASE_URL)
}
@Test
fun `when getVersionInfo and getVersion returns result then result`() = runTest {
coEvery { versionService.getVersion() } returns VersionResponse(true, "v0.5.6", true)
assertThat(subject.getVersionInfo(TEST_BASE_URL)).isEqualTo(
VersionInfo(true, "v0.5.6", true)
)
}
}

View File

@@ -0,0 +1,68 @@
package gq.kirmanak.mealient.data.network
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionService
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import io.mockk.*
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import retrofit2.Retrofit
@OptIn(ExperimentalCoroutinesApi::class)
class RetrofitServiceFactoryTest {
@MockK
lateinit var retrofitBuilder: RetrofitBuilder
@MockK
lateinit var baseURLStorage: BaseURLStorage
@MockK
lateinit var retrofit: Retrofit
@MockK
lateinit var versionService: VersionService
lateinit var subject: ServiceFactory<VersionService>
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = retrofitBuilder.createServiceFactory(baseURLStorage)
coEvery { retrofitBuilder.buildRetrofit(any()) } returns retrofit
every { retrofit.create(eq(VersionService::class.java)) } returns versionService
coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL
}
@Test
fun `when provideService and url is null then url storage requested`() = runTest {
subject.provideService()
coVerify { baseURLStorage.requireBaseURL() }
}
@Test
fun `when provideService and url is null then service still provided`() = runTest {
assertThat(subject.provideService()).isEqualTo(versionService)
}
@Test
fun `when provideService called twice then builder called once`() = runTest {
subject.provideService()
subject.provideService()
coVerifyAll { retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL)) }
}
@Test
fun `when provideService called secondly with new url then builder called twice`() = runTest {
subject.provideService()
subject.provideService("new url")
coVerifyAll {
retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL))
retrofitBuilder.buildRetrofit(eq("new url"))
}
}
}

View File

@@ -1,7 +1,7 @@
package gq.kirmanak.mealient.data.recipes.impl package gq.kirmanak.mealient.data.recipes.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.ui.ImageLoader import gq.kirmanak.mealient.ui.ImageLoader
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
import io.mockk.coEvery import io.mockk.coEvery
@@ -16,7 +16,7 @@ class RecipeImageLoaderImplTest {
lateinit var subject: RecipeImageLoaderImpl lateinit var subject: RecipeImageLoaderImpl
@MockK @MockK
lateinit var authRepo: AuthRepo lateinit var baseURLStorage: BaseURLStorage
@MockK @MockK
lateinit var imageLoader: ImageLoader lateinit var imageLoader: ImageLoader
@@ -24,8 +24,8 @@ class RecipeImageLoaderImplTest {
@Before @Before
fun setUp() { fun setUp() {
MockKAnnotations.init(this) MockKAnnotations.init(this)
subject = RecipeImageLoaderImpl(imageLoader, authRepo) subject = RecipeImageLoaderImpl(imageLoader, baseURLStorage)
coEvery { authRepo.getBaseUrl() } returns "https://google.com/" prepareBaseURL("https://google.com/")
} }
@Test @Test
@@ -42,21 +42,21 @@ class RecipeImageLoaderImplTest {
@Test @Test
fun `when url is null then generated is null`() = runTest { fun `when url is null then generated is null`() = runTest {
coEvery { authRepo.getBaseUrl() } returns null prepareBaseURL(null)
val actual = subject.generateImageUrl("cake") val actual = subject.generateImageUrl("cake")
assertThat(actual).isNull() assertThat(actual).isNull()
} }
@Test @Test
fun `when url is blank then generated is null`() = runTest { fun `when url is blank then generated is null`() = runTest {
coEvery { authRepo.getBaseUrl() } returns " " prepareBaseURL(" ")
val actual = subject.generateImageUrl("cake") val actual = subject.generateImageUrl("cake")
assertThat(actual).isNull() assertThat(actual).isNull()
} }
@Test @Test
fun `when url is empty then generated is null`() = runTest { fun `when url is empty then generated is null`() = runTest {
coEvery { authRepo.getBaseUrl() } returns "" prepareBaseURL("")
val actual = subject.generateImageUrl("cake") val actual = subject.generateImageUrl("cake")
assertThat(actual).isNull() assertThat(actual).isNull()
} }
@@ -78,4 +78,8 @@ class RecipeImageLoaderImplTest {
val actual = subject.generateImageUrl(null) val actual = subject.generateImageUrl(null)
assertThat(actual).isNull() assertThat(actual).isNull()
} }
private fun prepareBaseURL(baseURL: String?) {
coEvery { baseURLStorage.getBaseURL() } returns baseURL
}
} }

View File

@@ -3,7 +3,7 @@ package gq.kirmanak.mealient.data.recipes.impl
import androidx.paging.* import androidx.paging.*
import androidx.paging.LoadType.* import androidx.paging.LoadType.*
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.Unauthorized import gq.kirmanak.mealient.data.network.NetworkError.Unauthorized
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource

View File

@@ -0,0 +1,75 @@
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
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class BaseURLViewModelTest : RobolectricTest() {
@MockK(relaxUnitFun = true)
lateinit var baseURLStorage: BaseURLStorage
@MockK
lateinit var versionDataSource: VersionDataSource
lateinit var subject: BaseURLViewModel
@Before
fun setUp() {
MockKAnnotations.init(this)
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 {
versionDataSource.getVersionInfo(eq(TEST_BASE_URL))
} returns VersionInfo(true, "0.5.6", true)
subject.saveBaseUrl(TEST_BASE_URL)
advanceUntilIdle()
coVerify { baseURLStorage.storeBaseURL(eq(TEST_BASE_URL)) }
}
}

View File

@@ -1,7 +1,6 @@
package gq.kirmanak.mealient.ui.disclaimer package gq.kirmanak.mealient.ui.disclaimer
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
@@ -14,7 +13,6 @@ import org.junit.Test
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class DisclaimerViewModelTest { class DisclaimerViewModelTest {
@MockK(relaxUnitFun = true) @MockK(relaxUnitFun = true)
lateinit var storage: DisclaimerStorage lateinit var storage: DisclaimerStorage