Merge pull request #30 from kirmanak/authenticator
Split URL input and authorization
This commit is contained in:
@@ -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) {
|
||||||
|
R.id.logout -> {
|
||||||
authViewModel.logout()
|
authViewModel.logout()
|
||||||
true
|
true
|
||||||
} else {
|
}
|
||||||
super.onOptionsItemSelected(item)
|
R.id.login -> {
|
||||||
|
authViewModel.login()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package gq.kirmanak.mealient.data.baseurl
|
||||||
|
|
||||||
|
data class VersionInfo(
|
||||||
|
val production: Boolean,
|
||||||
|
val version: String,
|
||||||
|
val demoStatus: Boolean,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package gq.kirmanak.mealient.data.baseurl
|
||||||
|
|
||||||
|
import retrofit2.http.GET
|
||||||
|
|
||||||
|
interface VersionService {
|
||||||
|
@GET("api/debug/version")
|
||||||
|
suspend fun getVersion(): VersionResponse
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
new
|
|
||||||
} else {
|
|
||||||
cached
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createService(url: String, serviceClass: Class<T>): T {
|
||||||
|
Timber.v("createService() called with: url = $url, serviceClass = ${serviceClass.simpleName}")
|
||||||
|
val service = retrofitBuilder.buildRetrofit(url).create(serviceClass)
|
||||||
|
cache[url] = service
|
||||||
|
return service
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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')"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -25,9 +26,10 @@ interface AuthModule {
|
|||||||
|
|
||||||
@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
|
||||||
|
|||||||
35
app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt
Normal file
35
app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?) {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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?) {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
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"
|
||||||
@@ -41,22 +41,6 @@
|
|||||||
android:inputType="textPassword" />
|
android:inputType="textPassword" />
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
<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_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
|
<Button
|
||||||
android:id="@+id/button"
|
android:id="@+id/button"
|
||||||
android:text="@string/fragment_authentication_button_login"
|
android:text="@string/fragment_authentication_button_login"
|
||||||
@@ -64,5 +48,5 @@
|
|||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
style="@style/SmallMarginButton"
|
style="@style/SmallMarginButton"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/url_input_layout" />
|
app:layout_constraintTop_toBottomOf="@+id/password_input_layout" />
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
34
app/src/main/res/layout/fragment_base_url.xml
Normal file
34
app/src/main/res/layout/fragment_base_url.xml
Normal 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>
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
<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
|
||||||
|
android:id="@+id/login"
|
||||||
|
android:contentDescription="@string/menu_main_toolbar_content_description_login"
|
||||||
|
android:title="@string/menu_main_toolbar_login"
|
||||||
|
app:showAsAction="never" />
|
||||||
|
|
||||||
<item
|
<item
|
||||||
android:id="@+id/logout"
|
android:id="@+id/logout"
|
||||||
android:contentDescription="@string/menu_main_toolbar_content_description_logout"
|
android:contentDescription="@string/menu_main_toolbar_content_description_logout"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user