Simplify network layer (#175)

* Use Ktor for network requests

* Remove V0 version

* Remove Retrofit dependency

* Fix url

* Update versions of dependencies

* Revert kotlinx-datetime

Due to https://github.com/Kotlin/kotlinx-datetime/issues/304

* Rename leftovers

* Remove OkHttp

* Remove unused manifest

* Remove unused Hilt module

* Fix building empty image URLs

* Use OkHttp as engine for Ktor

* Reduce visibility of internal classes

* Fix first set up test

* Store only auth token, not header

* Remove UnitInfo/FoodInfo/VersionInfo/NewShoppingListItemInfo

* Remove RecipeSummaryInfo and ShoppingListsInfo

* Remove FullShoppingListInfo

* Remove ParseRecipeURLInfo

* Remove FullRecipeInfo

* Sign out if access token does not work

* Rename getVersionInfo method

* Update version name
This commit is contained in:
Kirill Kamakin
2023-11-05 15:01:19 +01:00
committed by GitHub
parent 888783bf14
commit 5ed1acb678
144 changed files with 1216 additions and 2796 deletions

View File

@@ -16,8 +16,8 @@ plugins {
android { android {
defaultConfig { defaultConfig {
applicationId = "gq.kirmanak.mealient" applicationId = "gq.kirmanak.mealient"
versionCode = 30 versionCode = 31
versionName = "0.4.1" versionName = "0.4.2"
testInstrumentationRunner = "gq.kirmanak.mealient.MealientTestRunner" testInstrumentationRunner = "gq.kirmanak.mealient.MealientTestRunner"
testInstrumentationRunnerArguments += mapOf("clearPackageData" to "true") testInstrumentationRunnerArguments += mapOf("clearPackageData" to "true")
resourceConfigurations += listOf("en", "es", "ru", "fr", "nl", "pt", "de") resourceConfigurations += listOf("en", "es", "ru", "fr", "nl", "pt", "de")
@@ -55,7 +55,7 @@ android {
namespace = "gq.kirmanak.mealient" namespace = "gq.kirmanak.mealient"
packagingOptions { packaging {
resources.excludes += "DebugProbesKt.bin" resources.excludes += "DebugProbesKt.bin"
} }

View File

@@ -5,11 +5,15 @@ import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest import okhttp3.mockwebserver.RecordedRequest
val versionV1Response = MockResponse().setResponseCode(200).setBody( val versionV1Response = MockResponse()
"""{"production":true,"version":"v1.0.0beta-5","demoStatus":false,"allowSignup":true}""" .setResponseCode(200)
) .setHeader("Content-Type", "application/json")
.setBody("""{"production":true,"version":"v1.0.0beta-5","demoStatus":false,"allowSignup":true}""")
val notFoundResponse = MockResponse().setResponseCode(404).setBody("""{"detail":"Not found"}"""") val notFoundResponse = MockResponse()
.setResponseCode(404)
.setHeader("Content-Type", "application/json")
.setBody("""{"detail":"Not found"}"""")
fun MockWebServer.dispatch(block: (String, RecordedRequest) -> MockResponse) { fun MockWebServer.dispatch(block: (String, RecordedRequest) -> MockResponse) {
dispatcher = object : Dispatcher() { dispatcher = object : Dispatcher() {

View File

@@ -9,7 +9,7 @@ interface AuthRepo : ShoppingListsAuthRepo {
suspend fun authenticate(email: String, password: String) suspend fun authenticate(email: String, password: String)
suspend fun getAuthHeader(): String? suspend fun getAuthToken(): String?
suspend fun logout() suspend fun logout()
} }

View File

@@ -4,9 +4,9 @@ import kotlinx.coroutines.flow.Flow
interface AuthStorage { interface AuthStorage {
val authHeaderFlow: Flow<String?> val authTokenFlow: Flow<String?>
suspend fun setAuthHeader(authHeader: String?) suspend fun setAuthToken(authToken: String?)
suspend fun getAuthHeader(): String? suspend fun getAuthToken(): String?
} }

View File

@@ -1,32 +1,19 @@
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.baseurl.ServerInfoRepo import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.data.baseurl.ServerVersion import gq.kirmanak.mealient.datasource.models.CreateApiTokenRequest
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v0.models.CreateApiTokenRequestV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenRequestV1
import javax.inject.Inject import javax.inject.Inject
class AuthDataSourceImpl @Inject constructor( class AuthDataSourceImpl @Inject constructor(
private val serverInfoRepo: ServerInfoRepo, private val dataSource: MealieDataSource,
private val v0Source: MealieDataSourceV0,
private val v1Source: MealieDataSourceV1,
) : AuthDataSource { ) : AuthDataSource {
private suspend fun getVersion(): ServerVersion = serverInfoRepo.getVersion() override suspend fun authenticate(username: String, password: String): String {
return dataSource.authenticate(username, password)
override suspend fun authenticate(
username: String,
password: String,
): String = when (getVersion()) {
ServerVersion.V0 -> v0Source.authenticate(username, password)
ServerVersion.V1 -> v1Source.authenticate(username, password)
} }
override suspend fun createApiToken(name: String): String = when (getVersion()) { override suspend fun createApiToken(name: String): String {
ServerVersion.V0 -> v0Source.createApiToken(CreateApiTokenRequestV0(name)).token return dataSource.createApiToken(CreateApiTokenRequest(name)).token
ServerVersion.V1 -> v1Source.createApiToken(CreateApiTokenRequestV1(name)).token
} }
} }

View File

@@ -4,6 +4,7 @@ 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.datasource.AuthenticationProvider import gq.kirmanak.mealient.datasource.AuthenticationProvider
import gq.kirmanak.mealient.datasource.SignOutHandler
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -13,28 +14,29 @@ class AuthRepoImpl @Inject constructor(
private val authStorage: AuthStorage, private val authStorage: AuthStorage,
private val authDataSource: AuthDataSource, private val authDataSource: AuthDataSource,
private val logger: Logger, private val logger: Logger,
private val signOutHandler: SignOutHandler,
) : AuthRepo, AuthenticationProvider { ) : AuthRepo, AuthenticationProvider {
override val isAuthorizedFlow: Flow<Boolean> override val isAuthorizedFlow: Flow<Boolean>
get() = authStorage.authHeaderFlow.map { it != null } get() = authStorage.authTokenFlow.map { it != null }
override suspend fun authenticate(email: String, password: String) { override suspend fun authenticate(email: String, password: String) {
logger.v { "authenticate() called with: email = $email, password = $password" } logger.v { "authenticate() called with: email = $email, password = $password" }
val token = authDataSource.authenticate(email, password) val token = authDataSource.authenticate(email, password)
authStorage.setAuthHeader(AUTH_HEADER_FORMAT.format(token)) authStorage.setAuthToken(token)
val apiToken = authDataSource.createApiToken(API_TOKEN_NAME) val apiToken = authDataSource.createApiToken(API_TOKEN_NAME)
authStorage.setAuthHeader(AUTH_HEADER_FORMAT.format(apiToken)) authStorage.setAuthToken(apiToken)
} }
override suspend fun getAuthHeader(): String? = authStorage.getAuthHeader() override suspend fun getAuthToken(): String? = authStorage.getAuthToken()
override suspend fun logout() { override suspend fun logout() {
logger.v { "logout() called" } logger.v { "logout() called" }
authStorage.setAuthHeader(null) authStorage.setAuthToken(null)
signOutHandler.signOut()
} }
companion object { companion object {
private const val AUTH_HEADER_FORMAT = "Bearer %s"
private const val API_TOKEN_NAME = "Mealient" private const val API_TOKEN_NAME = "Mealient"
} }
} }

View File

@@ -22,15 +22,15 @@ class AuthStorageImpl @Inject constructor(
private val logger: Logger, private val logger: Logger,
) : AuthStorage { ) : AuthStorage {
override val authHeaderFlow: Flow<String?> override val authTokenFlow: Flow<String?>
get() = sharedPreferences get() = sharedPreferences
.prefsChangeFlow(logger) { getString(AUTH_HEADER_KEY, null) } .prefsChangeFlow(logger) { getString(AUTH_TOKEN_KEY, null) }
.distinctUntilChanged() .distinctUntilChanged()
private val singleThreadDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() private val singleThreadDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
override suspend fun setAuthHeader(authHeader: String?) = putString(AUTH_HEADER_KEY, authHeader) override suspend fun setAuthToken(authToken: String?) = putString(AUTH_TOKEN_KEY, authToken)
override suspend fun getAuthHeader(): String? = getString(AUTH_HEADER_KEY) override suspend fun getAuthToken(): String? = getString(AUTH_TOKEN_KEY)
private suspend fun putString( private suspend fun putString(
key: String, key: String,
@@ -48,6 +48,6 @@ class AuthStorageImpl @Inject constructor(
companion object { companion object {
@VisibleForTesting @VisibleForTesting
const val AUTH_HEADER_KEY = "authHeader" const val AUTH_TOKEN_KEY = "authToken"
} }
} }

View File

@@ -1,16 +1,10 @@
package gq.kirmanak.mealient.data.baseurl package gq.kirmanak.mealient.data.baseurl
import kotlinx.coroutines.flow.Flow
interface ServerInfoRepo { interface ServerInfoRepo {
suspend fun getUrl(): String? suspend fun getUrl(): String?
suspend fun getVersion(): ServerVersion
suspend fun tryBaseURL(baseURL: String): Result<Unit> suspend fun tryBaseURL(baseURL: String): Result<Unit>
fun versionUpdates(): Flow<ServerVersion>
} }

View File

@@ -1,11 +1,7 @@
package gq.kirmanak.mealient.data.baseurl package gq.kirmanak.mealient.data.baseurl
import gq.kirmanak.mealient.datasource.ServerUrlProvider import gq.kirmanak.mealient.datasource.ServerUrlProvider
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
class ServerInfoRepoImpl @Inject constructor( class ServerInfoRepoImpl @Inject constructor(
@@ -20,47 +16,18 @@ class ServerInfoRepoImpl @Inject constructor(
return result return result
} }
override suspend fun getVersion(): ServerVersion {
var version = serverInfoStorage.getServerVersion()
val serverVersion = if (version == null) {
logger.d { "getVersion: version is null, requesting" }
version = versionDataSource.getVersionInfo().version
val result = determineServerVersion(version)
serverInfoStorage.storeServerVersion(version)
result
} else {
determineServerVersion(version)
}
logger.v { "getVersion() returned: $serverVersion from $version" }
return serverVersion
}
private fun determineServerVersion(version: String): ServerVersion = when {
version.startsWith("v0") -> ServerVersion.V0
version.startsWith("v1") -> ServerVersion.V1
else -> {
logger.w { "Unknown server version: $version" }
ServerVersion.V1
}
}
override suspend fun tryBaseURL(baseURL: String): Result<Unit> { override suspend fun tryBaseURL(baseURL: String): Result<Unit> {
val oldVersion = serverInfoStorage.getServerVersion()
val oldBaseUrl = serverInfoStorage.getBaseURL() val oldBaseUrl = serverInfoStorage.getBaseURL()
serverInfoStorage.storeBaseURL(baseURL)
return runCatchingExceptCancel { try {
serverInfoStorage.storeBaseURL(baseURL) versionDataSource.requestVersion()
val version = versionDataSource.getVersionInfo().version } catch (e: Throwable) {
serverInfoStorage.storeServerVersion(version) serverInfoStorage.storeBaseURL(oldBaseUrl)
}.onFailure { return Result.failure(e)
serverInfoStorage.storeBaseURL(oldBaseUrl, oldVersion)
} }
return Result.success(Unit)
} }
override fun versionUpdates(): Flow<ServerVersion> {
return serverInfoStorage
.serverVersionUpdates()
.filterNotNull()
.map { determineServerVersion(it) }
}
} }

View File

@@ -1,18 +1,9 @@
package gq.kirmanak.mealient.data.baseurl package gq.kirmanak.mealient.data.baseurl
import kotlinx.coroutines.flow.Flow
interface ServerInfoStorage { interface ServerInfoStorage {
suspend fun getBaseURL(): String? suspend fun getBaseURL(): String?
suspend fun storeBaseURL(baseURL: String) suspend fun storeBaseURL(baseURL: String?)
suspend fun storeBaseURL(baseURL: String?, version: String?)
suspend fun storeServerVersion(version: String)
suspend fun getServerVersion(): String?
fun serverVersionUpdates(): Flow<String?>
} }

View File

@@ -1,3 +0,0 @@
package gq.kirmanak.mealient.data.baseurl
enum class ServerVersion { V0, V1 }

View File

@@ -1,8 +1,8 @@
package gq.kirmanak.mealient.data.baseurl package gq.kirmanak.mealient.data.baseurl
import gq.kirmanak.mealient.datasource.models.VersionInfo import gq.kirmanak.mealient.datasource.models.VersionResponse
interface VersionDataSource { interface VersionDataSource {
suspend fun getVersionInfo(): VersionInfo suspend fun requestVersion(): VersionResponse
} }

View File

@@ -1,37 +1,14 @@
package gq.kirmanak.mealient.data.baseurl package gq.kirmanak.mealient.data.baseurl
import gq.kirmanak.mealient.datasource.models.VersionInfo import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.datasource.models.VersionResponse
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.model_mapper.ModelMapper
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import javax.inject.Inject import javax.inject.Inject
class VersionDataSourceImpl @Inject constructor( class VersionDataSourceImpl @Inject constructor(
private val v0Source: MealieDataSourceV0, private val dataSource: MealieDataSource,
private val v1Source: MealieDataSourceV1,
private val modelMapper: ModelMapper,
) : VersionDataSource { ) : VersionDataSource {
override suspend fun getVersionInfo(): VersionInfo { override suspend fun requestVersion(): VersionResponse {
val responses = coroutineScope { return dataSource.getVersionInfo()
val v0Deferred = async {
runCatchingExceptCancel { modelMapper.toVersionInfo(v0Source.getVersionInfo()) }
}
val v1Deferred = async {
runCatchingExceptCancel { modelMapper.toVersionInfo(v1Source.getVersionInfo()) }
}
listOf(v0Deferred, v1Deferred).awaitAll()
}
val firstSuccess = responses.firstNotNullOfOrNull { it.getOrNull() }
if (firstSuccess == null) {
throw responses.firstNotNullOf { it.exceptionOrNull() }
} else {
return firstSuccess
}
} }
} }

View File

@@ -3,7 +3,6 @@ package gq.kirmanak.mealient.data.baseurl.impl
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import gq.kirmanak.mealient.data.baseurl.ServerInfoStorage import gq.kirmanak.mealient.data.baseurl.ServerInfoStorage
import gq.kirmanak.mealient.data.storage.PreferencesStorage import gq.kirmanak.mealient.data.storage.PreferencesStorage
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
class ServerInfoStorageImpl @Inject constructor( class ServerInfoStorageImpl @Inject constructor(
@@ -13,45 +12,16 @@ class ServerInfoStorageImpl @Inject constructor(
private val baseUrlKey: Preferences.Key<String> private val baseUrlKey: Preferences.Key<String>
get() = preferencesStorage.baseUrlKey get() = preferencesStorage.baseUrlKey
private val serverVersionKey: Preferences.Key<String>
get() = preferencesStorage.serverVersionKey
override suspend fun getBaseURL(): String? = getValue(baseUrlKey) override suspend fun getBaseURL(): String? = getValue(baseUrlKey)
override suspend fun storeBaseURL(baseURL: String) { override suspend fun storeBaseURL(baseURL: String?) {
preferencesStorage.storeValues(Pair(baseUrlKey, baseURL)) if (baseURL == null) {
preferencesStorage.removeValues(serverVersionKey) preferencesStorage.removeValues(baseUrlKey)
} } else {
preferencesStorage.storeValues(Pair(baseUrlKey, baseURL))
override suspend fun storeBaseURL(baseURL: String?, version: String?) {
when {
baseURL == null -> {
preferencesStorage.removeValues(baseUrlKey, serverVersionKey)
}
version != null -> {
preferencesStorage.storeValues(
Pair(baseUrlKey, baseURL), Pair(serverVersionKey, version)
)
}
else -> {
preferencesStorage.removeValues(serverVersionKey)
preferencesStorage.storeValues(Pair(baseUrlKey, baseURL))
}
} }
} }
override suspend fun getServerVersion(): String? = getValue(serverVersionKey)
override suspend fun storeServerVersion(version: String) {
preferencesStorage.storeValues(Pair(serverVersionKey, version))
}
override fun serverVersionUpdates(): Flow<String?> {
return preferencesStorage.valueUpdates(serverVersionKey)
}
private suspend fun <T> getValue(key: Preferences.Key<T>): T? = preferencesStorage.getValue(key) private suspend fun <T> getValue(key: Preferences.Key<T>): T? = preferencesStorage.getValue(key)
} }

View File

@@ -0,0 +1,32 @@
package gq.kirmanak.mealient.data.migration
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import gq.kirmanak.mealient.datastore.DataStoreModule
import javax.inject.Inject
import javax.inject.Named
class From30MigrationExecutor @Inject constructor(
@Named(DataStoreModule.ENCRYPTED) private val sharedPreferences: SharedPreferences,
private val dataStore: DataStore<Preferences>,
) : MigrationExecutor {
override val migratingFrom: Int = 30
override suspend fun executeMigration() {
dataStore.edit { prefs ->
prefs -= stringPreferencesKey("serverVersion")
}
val authHeader = sharedPreferences.getString("authHeader", null)
if (authHeader != null) {
sharedPreferences.edit {
val authToken = authHeader.removePrefix("Bearer ")
putString("authToken", authToken)
}
}
}
}

View File

@@ -1,92 +1,58 @@
package gq.kirmanak.mealient.data.network package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.add.AddRecipeDataSource import gq.kirmanak.mealient.data.add.AddRecipeDataSource
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.ServerVersion
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.data.share.ParseRecipeDataSource import gq.kirmanak.mealient.data.share.ParseRecipeDataSource
import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.datasource.models.AddRecipeInfo import gq.kirmanak.mealient.datasource.models.AddRecipeInfo
import gq.kirmanak.mealient.datasource.models.FullRecipeInfo import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLInfo import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
import gq.kirmanak.mealient.datasource.models.RecipeSummaryInfo import gq.kirmanak.mealient.datasource.models.ParseRecipeURLRequest
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.model_mapper.ModelMapper import gq.kirmanak.mealient.model_mapper.ModelMapper
import javax.inject.Inject import javax.inject.Inject
class MealieDataSourceWrapper @Inject constructor( class MealieDataSourceWrapper @Inject constructor(
private val serverInfoRepo: ServerInfoRepo, private val dataSource: MealieDataSource,
private val v0Source: MealieDataSourceV0,
private val v1Source: MealieDataSourceV1,
private val modelMapper: ModelMapper, private val modelMapper: ModelMapper,
) : AddRecipeDataSource, RecipeDataSource, ParseRecipeDataSource { ) : AddRecipeDataSource, RecipeDataSource, ParseRecipeDataSource {
private suspend fun getVersion(): ServerVersion = serverInfoRepo.getVersion() override suspend fun addRecipe(recipe: AddRecipeInfo): String {
val slug = dataSource.createRecipe(modelMapper.toCreateRequest(recipe))
override suspend fun addRecipe(recipe: AddRecipeInfo): String = when (getVersion()) { dataSource.updateRecipe(slug, modelMapper.toUpdateRequest(recipe))
ServerVersion.V0 -> v0Source.addRecipe(modelMapper.toV0Request(recipe)) return slug
ServerVersion.V1 -> {
val slug = v1Source.createRecipe(modelMapper.toV1CreateRequest(recipe))
v1Source.updateRecipe(slug, modelMapper.toV1UpdateRequest(recipe))
slug
}
} }
override suspend fun requestRecipes( override suspend fun requestRecipes(
start: Int, start: Int,
limit: Int, limit: Int,
): List<RecipeSummaryInfo> = when (getVersion()) { ): List<GetRecipeSummaryResponse> {
ServerVersion.V0 -> { // Imagine start is 30 and limit is 15. It means that we already have page 1 and 2, now we need page 3
v0Source.requestRecipes(start, limit).map { modelMapper.toRecipeSummaryInfo(it) } val page = start / limit + 1
} return dataSource.requestRecipes(page, limit)
ServerVersion.V1 -> { }
// Imagine start is 30 and limit is 15. It means that we already have page 1 and 2, now we need page 3
val page = start / limit + 1 override suspend fun requestRecipe(slug: String): GetRecipeResponse {
v1Source.requestRecipes(page, limit).map { modelMapper.toRecipeSummaryInfo(it) } return dataSource.requestRecipeInfo(slug)
}
override suspend fun parseRecipeFromURL(parseRecipeURLInfo: ParseRecipeURLRequest): String {
return dataSource.parseRecipeFromURL(parseRecipeURLInfo)
}
override suspend fun getFavoriteRecipes(): List<String> {
return dataSource.requestUserInfo().favoriteRecipes
}
override suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean) {
val userId = dataSource.requestUserInfo().id
if (isFavorite) {
dataSource.addFavoriteRecipe(userId, recipeSlug)
} else {
dataSource.removeFavoriteRecipe(userId, recipeSlug)
} }
} }
override suspend fun requestRecipeInfo(slug: String): FullRecipeInfo = when (getVersion()) { override suspend fun deleteRecipe(recipeSlug: String) {
ServerVersion.V0 -> modelMapper.toFullRecipeInfo(v0Source.requestRecipeInfo(slug)) dataSource.deleteRecipe(recipeSlug)
ServerVersion.V1 -> modelMapper.toFullRecipeInfo(v1Source.requestRecipeInfo(slug))
}
override suspend fun parseRecipeFromURL(
parseRecipeURLInfo: ParseRecipeURLInfo,
): String = when (getVersion()) {
ServerVersion.V0 -> v0Source.parseRecipeFromURL(modelMapper.toV0Request(parseRecipeURLInfo))
ServerVersion.V1 -> v1Source.parseRecipeFromURL(modelMapper.toV1Request(parseRecipeURLInfo))
}
override suspend fun getFavoriteRecipes(): List<String> = when (getVersion()) {
ServerVersion.V0 -> v0Source.requestUserInfo().favoriteRecipes
ServerVersion.V1 -> v1Source.requestUserInfo().favoriteRecipes
}
override suspend fun updateIsRecipeFavorite(
recipeSlug: String,
isFavorite: Boolean
) = when (getVersion()) {
ServerVersion.V0 -> {
val userId = v0Source.requestUserInfo().id
if (isFavorite) {
v0Source.addFavoriteRecipe(userId, recipeSlug)
} else {
v0Source.removeFavoriteRecipe(userId, recipeSlug)
}
}
ServerVersion.V1 -> {
val userId = v1Source.requestUserInfo().id
if (isFavorite) {
v1Source.addFavoriteRecipe(userId, recipeSlug)
} else {
v1Source.removeFavoriteRecipe(userId, recipeSlug)
}
}
}
override suspend fun deleteRecipe(recipeSlug: String) = when (getVersion()) {
ServerVersion.V0 -> v0Source.deleteRecipe(recipeSlug)
ServerVersion.V1 -> v1Source.deleteRecipe(recipeSlug)
} }
} }

View File

@@ -1,8 +1,8 @@
package gq.kirmanak.mealient.data.recipes.impl package gq.kirmanak.mealient.data.recipes.impl
import android.net.Uri
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import javax.inject.Inject import javax.inject.Inject
class RecipeImageUrlProviderImpl @Inject constructor( class RecipeImageUrlProviderImpl @Inject constructor(
@@ -15,9 +15,11 @@ class RecipeImageUrlProviderImpl @Inject constructor(
slug?.takeUnless { it.isBlank() } ?: return null slug?.takeUnless { it.isBlank() } ?: return null
val imagePath = IMAGE_PATH_FORMAT.format(slug) val imagePath = IMAGE_PATH_FORMAT.format(slug)
val baseUrl = serverInfoRepo.getUrl()?.takeUnless { it.isEmpty() } val baseUrl = serverInfoRepo.getUrl()?.takeUnless { it.isEmpty() }
val result = baseUrl?.toHttpUrlOrNull() val result = baseUrl
?.newBuilder() ?.takeUnless { it.isBlank() }
?.addPathSegments(imagePath) ?.let { Uri.parse(it) }
?.buildUpon()
?.path(imagePath)
?.build() ?.build()
?.toString() ?.toString()
logger.v { "getRecipeImageUrl() returned: $result" } logger.v { "getRecipeImageUrl() returned: $result" }

View File

@@ -45,7 +45,7 @@ class RecipeRepoImpl @Inject constructor(
override suspend fun refreshRecipeInfo(recipeSlug: String): Result<Unit> { override suspend fun refreshRecipeInfo(recipeSlug: String): Result<Unit> {
logger.v { "refreshRecipeInfo() called with: recipeSlug = $recipeSlug" } logger.v { "refreshRecipeInfo() called with: recipeSlug = $recipeSlug" }
return runCatchingExceptCancel { return runCatchingExceptCancel {
val info = dataSource.requestRecipeInfo(recipeSlug) val info = dataSource.requestRecipe(recipeSlug)
val entity = modelMapper.toRecipeEntity(info) val entity = modelMapper.toRecipeEntity(info)
val ingredients = info.recipeIngredients.map { val ingredients = info.recipeIngredients.map {
modelMapper.toRecipeIngredientEntity(it, entity.remoteId) modelMapper.toRecipeIngredientEntity(it, entity.remoteId)

View File

@@ -1,12 +1,12 @@
package gq.kirmanak.mealient.data.recipes.network package gq.kirmanak.mealient.data.recipes.network
import gq.kirmanak.mealient.datasource.models.FullRecipeInfo import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.RecipeSummaryInfo import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
interface RecipeDataSource { interface RecipeDataSource {
suspend fun requestRecipes(start: Int, limit: Int): List<RecipeSummaryInfo> suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse>
suspend fun requestRecipeInfo(slug: String): FullRecipeInfo suspend fun requestRecipe(slug: String): GetRecipeResponse
suspend fun getFavoriteRecipes(): List<String> suspend fun getFavoriteRecipes(): List<String>

View File

@@ -1,8 +1,8 @@
package gq.kirmanak.mealient.data.share package gq.kirmanak.mealient.data.share
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLInfo import gq.kirmanak.mealient.datasource.models.ParseRecipeURLRequest
interface ParseRecipeDataSource { interface ParseRecipeDataSource {
suspend fun parseRecipeFromURL(parseRecipeURLInfo: ParseRecipeURLInfo): String suspend fun parseRecipeFromURL(parseRecipeURLInfo: ParseRecipeURLRequest): String
} }

View File

@@ -1,7 +1,7 @@
package gq.kirmanak.mealient.data.share package gq.kirmanak.mealient.data.share
import androidx.core.util.PatternsCompat import androidx.core.util.PatternsCompat
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLInfo import gq.kirmanak.mealient.datasource.models.ParseRecipeURLRequest
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject import javax.inject.Inject
@@ -15,7 +15,7 @@ class ShareRecipeRepoImpl @Inject constructor(
val matcher = PatternsCompat.WEB_URL.matcher(url) val matcher = PatternsCompat.WEB_URL.matcher(url)
require(matcher.find()) { "Can't find URL in the text" } require(matcher.find()) { "Can't find URL in the text" }
val urlString = matcher.group() val urlString = matcher.group()
val request = ParseRecipeURLInfo(url = urlString, includeTags = true) val request = ParseRecipeURLRequest(url = urlString, includeTags = true)
return parseRecipeDataSource.parseRecipeFromURL(request) return parseRecipeDataSource.parseRecipeFromURL(request)
} }
} }

View File

@@ -7,8 +7,6 @@ interface PreferencesStorage {
val baseUrlKey: Preferences.Key<String> val baseUrlKey: Preferences.Key<String>
val serverVersionKey: Preferences.Key<String>
val isDisclaimerAcceptedKey: Preferences.Key<Boolean> val isDisclaimerAcceptedKey: Preferences.Key<Boolean>
val lastExecutedMigrationVersionKey: Preferences.Key<Int> val lastExecutedMigrationVersionKey: Preferences.Key<Int>

View File

@@ -24,8 +24,6 @@ class PreferencesStorageImpl @Inject constructor(
override val baseUrlKey = stringPreferencesKey("baseUrl") override val baseUrlKey = stringPreferencesKey("baseUrl")
override val serverVersionKey = stringPreferencesKey("serverVersion")
override val isDisclaimerAcceptedKey = booleanPreferencesKey("isDisclaimedAccepted") override val isDisclaimerAcceptedKey = booleanPreferencesKey("isDisclaimedAccepted")
override val lastExecutedMigrationVersionKey: Preferences.Key<Int> = override val lastExecutedMigrationVersionKey: Preferences.Key<Int> =

View File

@@ -6,6 +6,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.data.migration.From24AuthMigrationExecutor import gq.kirmanak.mealient.data.migration.From24AuthMigrationExecutor
import gq.kirmanak.mealient.data.migration.From30MigrationExecutor
import gq.kirmanak.mealient.data.migration.MigrationDetector import gq.kirmanak.mealient.data.migration.MigrationDetector
import gq.kirmanak.mealient.data.migration.MigrationDetectorImpl import gq.kirmanak.mealient.data.migration.MigrationDetectorImpl
import gq.kirmanak.mealient.data.migration.MigrationExecutor import gq.kirmanak.mealient.data.migration.MigrationExecutor
@@ -18,6 +19,10 @@ interface MigrationModule {
@IntoSet @IntoSet
fun bindFrom24AuthMigrationExecutor(from24AuthMigrationExecutor: From24AuthMigrationExecutor): MigrationExecutor fun bindFrom24AuthMigrationExecutor(from24AuthMigrationExecutor: From24AuthMigrationExecutor): MigrationExecutor
@Binds
@IntoSet
fun bindFrom30MigrationExecutor(impl: From30MigrationExecutor): MigrationExecutor
@Binds @Binds
fun bindMigrationDetector(migrationDetectorImpl: MigrationDetectorImpl): MigrationDetector fun bindMigrationDetector(migrationDetectorImpl: MigrationDetectorImpl): MigrationDetector
} }

View File

@@ -109,7 +109,6 @@ class MainActivity : BaseActivity<MainActivityBinding>(
when (itemId) { when (itemId) {
R.id.logout -> menuItem.isVisible = uiState.canShowLogout R.id.logout -> menuItem.isVisible = uiState.canShowLogout
R.id.login -> menuItem.isVisible = uiState.canShowLogin R.id.login -> menuItem.isVisible = uiState.canShowLogin
R.id.shopping_lists -> menuItem.isVisible = uiState.v1MenuItemsVisible
} }
menuItem.isChecked = itemId == checkedMenuItem menuItem.isChecked = itemId == checkedMenuItem
} }

View File

@@ -8,7 +8,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.ServerVersion
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
@@ -47,10 +46,6 @@ class MainActivityViewModel @Inject constructor(
.onEach { isAuthorized -> updateUiState { it.copy(isAuthorized = isAuthorized) } } .onEach { isAuthorized -> updateUiState { it.copy(isAuthorized = isAuthorized) } }
.launchIn(viewModelScope) .launchIn(viewModelScope)
serverInfoRepo.versionUpdates()
.onEach { version -> updateUiState { it.copy(v1MenuItemsVisible = version == ServerVersion.V1) } }
.launchIn(viewModelScope)
viewModelScope.launch { viewModelScope.launch {
_startDestination.value = when { _startDestination.value = when {
!disclaimerStorage.isDisclaimerAccepted() -> { !disclaimerStorage.isDisclaimerAccepted() -> {

View File

@@ -14,7 +14,6 @@
<item <item
android:id="@+id/shopping_lists" android:id="@+id/shopping_lists"
android:visible="false"
android:checkable="true" android:checkable="true"
android:icon="@drawable/ic_shopping_cart" android:icon="@drawable/ic_shopping_cart"
android:title="@string/menu_navigation_drawer_shopping_lists" /> android:title="@string/menu_navigation_drawer_shopping_lists" />

View File

@@ -9,15 +9,16 @@ import gq.kirmanak.mealient.datastore_test.PORRIDGE_RECIPE_DRAFT
import gq.kirmanak.mealient.model_mapper.ModelMapper import gq.kirmanak.mealient.model_mapper.ModelMapper
import gq.kirmanak.mealient.model_mapper.ModelMapperImpl import gq.kirmanak.mealient.model_mapper.ModelMapperImpl
import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.* import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi import io.mockk.verify
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AddRecipeRepoTest : BaseUnitTest() { class AddRecipeRepoTest : BaseUnitTest() {
@MockK(relaxUnitFun = true) @MockK(relaxUnitFun = true)

View File

@@ -4,62 +4,59 @@ 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.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.baseurl.ServerInfoRepo import gq.kirmanak.mealient.datasource.SignOutHandler
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_API_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_API_TOKEN import gq.kirmanak.mealient.test.AuthImplTestData.TEST_API_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_SERVER_VERSION_V0
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.BaseUnitTest import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.* import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.confirmVerified
import io.mockk.every
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AuthRepoImplTest : BaseUnitTest() { class AuthRepoImplTest : BaseUnitTest() {
@MockK @MockK
lateinit var dataSource: AuthDataSource lateinit var dataSource: AuthDataSource
@MockK
lateinit var serverInfoRepo: ServerInfoRepo
@MockK(relaxUnitFun = true) @MockK(relaxUnitFun = true)
lateinit var storage: AuthStorage lateinit var storage: AuthStorage
@MockK(relaxUnitFun = true)
lateinit var signOutHandler: SignOutHandler
lateinit var subject: AuthRepo lateinit var subject: AuthRepo
@Before @Before
override fun setUp() { override fun setUp() {
super.setUp() super.setUp()
subject = AuthRepoImpl(storage, dataSource, logger) subject = AuthRepoImpl(storage, dataSource, logger, signOutHandler)
} }
@Test @Test
fun `when isAuthorizedFlow then reads from storage`() = runTest { fun `when isAuthorizedFlow then reads from storage`() = runTest {
every { storage.authHeaderFlow } returns flowOf("", null, "header") every { storage.authTokenFlow } returns flowOf("", null, "header")
assertThat(subject.isAuthorizedFlow.toList()).isEqualTo(listOf(true, false, true)) assertThat(subject.isAuthorizedFlow.toList()).isEqualTo(listOf(true, false, true))
} }
@Test @Test
fun `when authenticate successfully then saves to storage`() = runTest { fun `when authenticate successfully then saves to storage`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
coEvery { dataSource.authenticate(any(), any()) } returns TEST_TOKEN coEvery { dataSource.authenticate(any(), any()) } returns TEST_TOKEN
coEvery { dataSource.createApiToken(any()) } returns TEST_API_TOKEN coEvery { dataSource.createApiToken(any()) } returns TEST_API_TOKEN
subject.authenticate(TEST_USERNAME, TEST_PASSWORD) subject.authenticate(TEST_USERNAME, TEST_PASSWORD)
coVerify { coVerify {
dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD))
storage.setAuthHeader(TEST_AUTH_HEADER) storage.setAuthToken(TEST_TOKEN)
dataSource.createApiToken(eq("Mealient")) dataSource.createApiToken(eq("Mealient"))
storage.setAuthHeader(TEST_API_AUTH_HEADER) storage.setAuthToken(TEST_API_TOKEN)
} }
confirmVerified(storage) confirmVerified(storage)
} }
@@ -74,7 +71,7 @@ class AuthRepoImplTest : BaseUnitTest() {
@Test @Test
fun `when logout expect header removal`() = runTest { fun `when logout expect header removal`() = runTest {
subject.logout() subject.logout()
coVerify { storage.setAuthHeader(null) } coVerify { storage.setAuthToken(null) }
confirmVerified(storage) confirmVerified(storage)
} }
} }

View File

@@ -7,18 +7,16 @@ import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl.Companion.AUTH_HEADER_KEY import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl.Companion.AUTH_TOKEN_KEY
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.HiltRobolectricTest import gq.kirmanak.mealient.test.HiltRobolectricTest
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import javax.inject.Inject import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest @HiltAndroidTest
class AuthStorageImplTest : HiltRobolectricTest() { class AuthStorageImplTest : HiltRobolectricTest() {
@@ -39,12 +37,12 @@ class AuthStorageImplTest : HiltRobolectricTest() {
@Test @Test
fun `when authHeaderFlow is observed then sends value immediately`() = runTest { fun `when authHeaderFlow is observed then sends value immediately`() = runTest {
sharedPreferences.edit(commit = true) { putString(AUTH_HEADER_KEY, TEST_AUTH_HEADER) } sharedPreferences.edit(commit = true) { putString(AUTH_TOKEN_KEY, TEST_TOKEN) }
assertThat(subject.authHeaderFlow.first()).isEqualTo(TEST_AUTH_HEADER) assertThat(subject.authTokenFlow.first()).isEqualTo(TEST_TOKEN)
} }
@Test @Test
fun `when authHeader is observed then sends null if nothing saved`() = runTest { fun `when authHeader is observed then sends null if nothing saved`() = runTest {
assertThat(subject.authHeaderFlow.first()).isEqualTo(null) assertThat(subject.authTokenFlow.first()).isEqualTo(null)
} }
} }

View File

@@ -1,21 +1,15 @@
package gq.kirmanak.mealient.data.baseurl package gq.kirmanak.mealient.data.baseurl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.datasource.models.VersionInfo
import gq.kirmanak.mealient.datasource_test.VERSION_INFO_V0
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_VERSION
import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class ServerInfoRepoTest : BaseUnitTest() { class ServerInfoRepoTest : BaseUnitTest() {
private lateinit var subject: ServerInfoRepo private lateinit var subject: ServerInfoRepo
@@ -54,104 +48,10 @@ class ServerInfoRepoTest : BaseUnitTest() {
@Test @Test
fun `when tryBaseURL succeeds expect call to storage`() = runTest { fun `when tryBaseURL succeeds expect call to storage`() = runTest {
coEvery { storage.getServerVersion() } returns null
coEvery { storage.getBaseURL() } returns null coEvery { storage.getBaseURL() } returns null
coEvery { dataSource.getVersionInfo() } returns VersionInfo(TEST_VERSION)
subject.tryBaseURL(TEST_BASE_URL) subject.tryBaseURL(TEST_BASE_URL)
coVerify { coVerify {
storage.storeBaseURL(eq(TEST_BASE_URL)) storage.storeBaseURL(eq(TEST_BASE_URL))
dataSource.getVersionInfo()
storage.storeServerVersion(TEST_VERSION)
} }
} }
@Test
fun `when tryBaseURL fails expect call to storage`() = runTest {
coEvery { storage.getServerVersion() } returns "serverVersion"
coEvery { storage.getBaseURL() } returns "baseUrl"
coEvery { dataSource.getVersionInfo() } throws IOException()
subject.tryBaseURL(TEST_BASE_URL)
coVerify {
storage.storeBaseURL(eq(TEST_BASE_URL))
dataSource.getVersionInfo()
storage.storeBaseURL(eq("baseUrl"), eq("serverVersion"))
}
}
@Test
fun `when storage is empty expect getVersion to call data source`() = runTest {
coEvery { storage.getServerVersion() } returns null
coEvery { storage.getBaseURL() } returns TEST_BASE_URL
coEvery { dataSource.getVersionInfo() } returns VERSION_INFO_V0
subject.getVersion()
coVerify { dataSource.getVersionInfo() }
}
@Test
fun `when storage is empty and data source has value expect getVersion to save it`() = runTest {
coEvery { storage.getServerVersion() } returns null
coEvery { storage.getBaseURL() } returns TEST_BASE_URL
coEvery { dataSource.getVersionInfo() } returns VersionInfo(TEST_VERSION)
subject.getVersion()
coVerify { storage.storeServerVersion(TEST_VERSION) }
}
@Test
fun `when data source has invalid value expect getVersion to return v1`() = runTest {
coEvery { storage.getServerVersion() } returns null
coEvery { storage.getBaseURL() } returns TEST_BASE_URL
coEvery { dataSource.getVersionInfo() } returns VersionInfo("v2.0.0")
assertThat(subject.getVersion()).isEqualTo(ServerVersion.V1)
}
@Test
fun `when data source has invalid value expect getVersion to save value`() = runTest {
coEvery { storage.getServerVersion() } returns null
coEvery { storage.getBaseURL() } returns TEST_BASE_URL
coEvery { dataSource.getVersionInfo() } returns VersionInfo("v2.0.0")
subject.getVersion()
coVerify { storage.storeServerVersion("v2.0.0") }
}
@Test
fun `when storage has value expect getVersion to not get URL`() = runTest {
coEvery { storage.getServerVersion() } returns TEST_VERSION
subject.getVersion()
coVerify(inverse = true) { storage.getBaseURL() }
}
@Test
fun `when storage has value expect getVersion to not call data source`() = runTest {
coEvery { storage.getServerVersion() } returns TEST_VERSION
subject.getVersion()
coVerify(inverse = true) { dataSource.getVersionInfo() }
}
@Test
fun `when storage has v0 value expect getVersion to return parsed`() = runTest {
coEvery { storage.getServerVersion() } returns "v0.5.6"
assertThat(subject.getVersion()).isEqualTo(ServerVersion.V0)
}
@Test
fun `when storage has v1 value expect getVersion to return parsed`() = runTest {
coEvery { storage.getServerVersion() } returns "v1.0.0-beta05"
assertThat(subject.getVersion()).isEqualTo(ServerVersion.V1)
}
@Test
fun `when data source has valid v0 value expect getVersion to return it`() = runTest {
coEvery { storage.getServerVersion() } returns null
coEvery { storage.getBaseURL() } returns TEST_BASE_URL
coEvery { dataSource.getVersionInfo() } returns VersionInfo("v0.5.6")
assertThat(subject.getVersion()).isEqualTo(ServerVersion.V0)
}
@Test
fun `when data source has valid v1 value expect getVersion to return it`() = runTest {
coEvery { storage.getServerVersion() } returns null
coEvery { storage.getBaseURL() } returns TEST_BASE_URL
coEvery { dataSource.getVersionInfo() } returns VersionInfo("v1.0.0-beta05")
assertThat(subject.getVersion()).isEqualTo(ServerVersion.V1)
}
} }

View File

@@ -5,18 +5,15 @@ import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.baseurl.impl.ServerInfoStorageImpl import gq.kirmanak.mealient.data.baseurl.impl.ServerInfoStorageImpl
import gq.kirmanak.mealient.data.storage.PreferencesStorage import gq.kirmanak.mealient.data.storage.PreferencesStorage
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_VERSION
import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ServerInfoStorageTest : BaseUnitTest() { class ServerInfoStorageTest : BaseUnitTest() {
@MockK(relaxUnitFun = true) @MockK(relaxUnitFun = true)
@@ -25,14 +22,12 @@ class ServerInfoStorageTest : BaseUnitTest() {
lateinit var subject: ServerInfoStorage lateinit var subject: ServerInfoStorage
private val baseUrlKey = stringPreferencesKey("baseUrlKey") private val baseUrlKey = stringPreferencesKey("baseUrlKey")
private val serverVersionKey = stringPreferencesKey("serverVersionKey")
@Before @Before
override fun setUp() { override fun setUp() {
super.setUp() super.setUp()
subject = ServerInfoStorageImpl(preferencesStorage) subject = ServerInfoStorageImpl(preferencesStorage)
every { preferencesStorage.baseUrlKey } returns baseUrlKey every { preferencesStorage.baseUrlKey } returns baseUrlKey
every { preferencesStorage.serverVersionKey } returns serverVersionKey
} }
@Test @Test
@@ -49,30 +44,11 @@ class ServerInfoStorageTest : BaseUnitTest() {
@Test @Test
fun `when storeBaseURL expect call to preferences storage`() = runTest { fun `when storeBaseURL expect call to preferences storage`() = runTest {
subject.storeBaseURL(TEST_BASE_URL, TEST_VERSION) subject.storeBaseURL(TEST_BASE_URL)
coVerify { coVerify {
preferencesStorage.storeValues( preferencesStorage.storeValues(
eq(Pair(baseUrlKey, TEST_BASE_URL)), eq(Pair(baseUrlKey, TEST_BASE_URL)),
eq(Pair(serverVersionKey, TEST_VERSION)),
) )
} }
} }
@Test
fun `when preference storage is empty expect getServerVersion return null`() = runTest {
coEvery { preferencesStorage.getValue(eq(serverVersionKey)) } returns null
assertThat(subject.getServerVersion()).isNull()
}
@Test
fun `when preference storage has value expect getServerVersion return value`() = runTest {
coEvery { preferencesStorage.getValue(eq(serverVersionKey)) } returns TEST_VERSION
assertThat(subject.getServerVersion()).isEqualTo(TEST_VERSION)
}
@Test
fun `when storeServerVersion then calls preferences storage`() = runTest {
subject.storeServerVersion(TEST_VERSION)
coVerify { preferencesStorage.storeValues(eq(Pair(serverVersionKey, TEST_VERSION))) }
}
} }

View File

@@ -3,12 +3,10 @@ package gq.kirmanak.mealient.data.disclaimer
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.HiltRobolectricTest import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import javax.inject.Inject import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest @HiltAndroidTest
class DisclaimerStorageImplTest : HiltRobolectricTest() { class DisclaimerStorageImplTest : HiltRobolectricTest() {

View File

@@ -12,14 +12,12 @@ import io.mockk.MockKAnnotations
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest @HiltAndroidTest
class From24AuthMigrationExecutorTest : HiltRobolectricTest() { class From24AuthMigrationExecutorTest : HiltRobolectricTest() {

View File

@@ -9,11 +9,9 @@ import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class MigrationDetectorImplTest : BaseUnitTest() { class MigrationDetectorImplTest : BaseUnitTest() {
@MockK(relaxUnitFun = true) @MockK(relaxUnitFun = true)

View File

@@ -2,50 +2,35 @@ package gq.kirmanak.mealient.data.network
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.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.datasource_test.PORRIDGE_ADD_RECIPE_INFO import gq.kirmanak.mealient.datasource_test.PORRIDGE_ADD_RECIPE_INFO
import gq.kirmanak.mealient.datasource_test.PORRIDGE_ADD_RECIPE_REQUEST_V0 import gq.kirmanak.mealient.datasource_test.PORRIDGE_CREATE_RECIPE_REQUEST
import gq.kirmanak.mealient.datasource_test.PORRIDGE_CREATE_RECIPE_REQUEST_V1 import gq.kirmanak.mealient.datasource_test.PORRIDGE_RECIPE_RESPONSE
import gq.kirmanak.mealient.datasource_test.PORRIDGE_FULL_RECIPE_INFO import gq.kirmanak.mealient.datasource_test.PORRIDGE_RECIPE_SUMMARY_RESPONSE
import gq.kirmanak.mealient.datasource_test.PORRIDGE_RECIPE_RESPONSE_V1 import gq.kirmanak.mealient.datasource_test.PORRIDGE_UPDATE_RECIPE_REQUEST
import gq.kirmanak.mealient.datasource_test.PORRIDGE_RECIPE_SUMMARY_RESPONSE_V0 import gq.kirmanak.mealient.datasource_test.RECIPE_SUMMARY_PORRIDGE
import gq.kirmanak.mealient.datasource_test.PORRIDGE_RECIPE_SUMMARY_RESPONSE_V1
import gq.kirmanak.mealient.datasource_test.PORRIDGE_UPDATE_RECIPE_REQUEST_V1
import gq.kirmanak.mealient.datasource_test.RECIPE_SUMMARY_PORRIDGE_V0
import gq.kirmanak.mealient.datasource_test.RECIPE_SUMMARY_PORRIDGE_V1
import gq.kirmanak.mealient.model_mapper.ModelMapper import gq.kirmanak.mealient.model_mapper.ModelMapper
import gq.kirmanak.mealient.model_mapper.ModelMapperImpl import gq.kirmanak.mealient.model_mapper.ModelMapperImpl
import gq.kirmanak.mealient.test.AuthImplTestData.FAVORITE_RECIPES_LIST import gq.kirmanak.mealient.test.AuthImplTestData.FAVORITE_RECIPES_LIST
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_SERVER_VERSION_V0 import gq.kirmanak.mealient.test.AuthImplTestData.USER_INFO
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_SERVER_VERSION_V1
import gq.kirmanak.mealient.test.AuthImplTestData.USER_INFO_V0
import gq.kirmanak.mealient.test.AuthImplTestData.USER_INFO_V1
import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.* import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifySequence
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import java.io.IOException import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class MealieDataSourceWrapperTest : BaseUnitTest() { class MealieDataSourceWrapperTest : BaseUnitTest() {
@MockK
lateinit var serverInfoRepo: ServerInfoRepo
@MockK(relaxUnitFun = true) @MockK(relaxUnitFun = true)
lateinit var authRepo: AuthRepo lateinit var authRepo: AuthRepo
@MockK(relaxUnitFun = true) @MockK(relaxUnitFun = true)
lateinit var v0Source: MealieDataSourceV0 lateinit var dataSource: MealieDataSource
@MockK(relaxUnitFun = true)
lateinit var v1Source: MealieDataSourceV1
private val modelMapper: ModelMapper = ModelMapperImpl() private val modelMapper: ModelMapper = ModelMapperImpl()
@@ -54,111 +39,68 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
@Before @Before
override fun setUp() { override fun setUp() {
super.setUp() super.setUp()
subject = MealieDataSourceWrapper(serverInfoRepo, v0Source, v1Source, modelMapper) subject = MealieDataSourceWrapper(dataSource, modelMapper)
coEvery { v0Source.requestUserInfo() } returns USER_INFO_V0 coEvery { dataSource.requestUserInfo() } returns USER_INFO
coEvery { v1Source.requestUserInfo() } returns USER_INFO_V1
} }
@Test @Test
fun `when server version v1 expect requestRecipeInfo to call v1`() = runTest { fun `when requestRecipeInfo expect a valid network call`() = runTest {
val slug = "porridge" val slug = "porridge"
coEvery { v1Source.requestRecipeInfo(eq(slug)) } returns PORRIDGE_RECIPE_RESPONSE_V1 coEvery { dataSource.requestRecipeInfo(eq(slug)) } returns PORRIDGE_RECIPE_RESPONSE
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1 coEvery { authRepo.getAuthToken() } returns TEST_TOKEN
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
val actual = subject.requestRecipeInfo(slug) val actual = subject.requestRecipe(slug)
coVerify { v1Source.requestRecipeInfo(eq(slug)) } coVerify { dataSource.requestRecipeInfo(eq(slug)) }
assertThat(actual).isEqualTo(PORRIDGE_FULL_RECIPE_INFO) assertThat(actual).isEqualTo(PORRIDGE_RECIPE_RESPONSE)
} }
@Test @Test
fun `when server version v1 expect requestRecipes to call v1`() = runTest { fun `when requestRecipes expect valid network request`() = runTest {
coEvery { coEvery {
v1Source.requestRecipes(any(), any()) dataSource.requestRecipes(any(), any())
} returns listOf(PORRIDGE_RECIPE_SUMMARY_RESPONSE_V1) } returns listOf(PORRIDGE_RECIPE_SUMMARY_RESPONSE)
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1 coEvery { authRepo.getAuthToken() } returns TEST_TOKEN
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
val actual = subject.requestRecipes(40, 10) val actual = subject.requestRecipes(40, 10)
val page = 5 // 0-9 (1), 10-19 (2), 20-29 (3), 30-39 (4), 40-49 (5) val page = 5 // 0-9 (1), 10-19 (2), 20-29 (3), 30-39 (4), 40-49 (5)
val perPage = 10 val perPage = 10
coVerify { coVerify {
v1Source.requestRecipes(eq(page), eq(perPage)) dataSource.requestRecipes(eq(page), eq(perPage))
} }
assertThat(actual).isEqualTo(listOf(RECIPE_SUMMARY_PORRIDGE_V1)) assertThat(actual).isEqualTo(listOf(RECIPE_SUMMARY_PORRIDGE))
}
@Test
fun `when server version v0 expect requestRecipes to call v0`() = runTest {
coEvery {
v0Source.requestRecipes(any(), any())
} returns listOf(PORRIDGE_RECIPE_SUMMARY_RESPONSE_V0)
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
val start = 40
val limit = 10
val actual = subject.requestRecipes(start, limit)
coVerify {
v0Source.requestRecipes(eq(start), eq(limit))
}
assertThat(actual).isEqualTo(listOf(RECIPE_SUMMARY_PORRIDGE_V0))
} }
@Test(expected = IOException::class) @Test(expected = IOException::class)
fun `when request fails expect addRecipe to rethrow`() = runTest { fun `when request fails expect createRecipe to rethrow`() = runTest {
coEvery { v0Source.addRecipe(any()) } throws IOException() coEvery { dataSource.createRecipe(any()) } throws IOException()
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0 coEvery { authRepo.getAuthToken() } returns TEST_TOKEN
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
subject.addRecipe(PORRIDGE_ADD_RECIPE_INFO) subject.addRecipe(PORRIDGE_ADD_RECIPE_INFO)
} }
@Test @Test
fun `when server version v0 expect addRecipe to call v0`() = runTest { fun `when create recipe expect createRecipe to call in sequence`() = runTest {
val slug = "porridge" val slug = "porridge"
coEvery { v0Source.addRecipe(any()) } returns slug coEvery { dataSource.createRecipe(any()) } returns slug
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
val actual = subject.addRecipe(PORRIDGE_ADD_RECIPE_INFO)
coVerify {
v0Source.addRecipe(
eq(PORRIDGE_ADD_RECIPE_REQUEST_V0),
)
}
assertThat(actual).isEqualTo(slug)
}
@Test
fun `when server version v1 expect addRecipe to call v1`() = runTest {
val slug = "porridge"
coEvery { v1Source.createRecipe(any()) } returns slug
coEvery { coEvery {
v1Source.updateRecipe(any(), any()) dataSource.updateRecipe(any(), any())
} returns PORRIDGE_RECIPE_RESPONSE_V1 } returns PORRIDGE_RECIPE_RESPONSE
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1 coEvery { authRepo.getAuthToken() } returns TEST_TOKEN
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
val actual = subject.addRecipe(PORRIDGE_ADD_RECIPE_INFO) val actual = subject.addRecipe(PORRIDGE_ADD_RECIPE_INFO)
coVerifySequence { coVerifySequence {
v1Source.createRecipe( dataSource.createRecipe(
eq(PORRIDGE_CREATE_RECIPE_REQUEST_V1), eq(PORRIDGE_CREATE_RECIPE_REQUEST),
) )
v1Source.updateRecipe( dataSource.updateRecipe(
eq(slug), eq(slug),
eq(PORRIDGE_UPDATE_RECIPE_REQUEST_V1), eq(PORRIDGE_UPDATE_RECIPE_REQUEST),
) )
} }
@@ -166,68 +108,31 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
} }
@Test @Test
fun `when remove favorite recipe info with v0 expect correct sequence`() = runTest { fun `when remove favorite recipe info expect correct sequence`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = false) subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = false)
coVerify { coVerify {
v0Source.requestUserInfo() dataSource.requestUserInfo()
v0Source.removeFavoriteRecipe(eq(3), eq("cake")) dataSource.removeFavoriteRecipe(eq("userId"), eq("cake"))
} }
} }
@Test @Test
fun `when remove favorite recipe info with v1 expect correct sequence`() = runTest { fun `when add favorite recipe info expect correct sequence`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = false)
coVerify {
v1Source.requestUserInfo()
v1Source.removeFavoriteRecipe(eq("userId"), eq("cake"))
}
}
@Test
fun `when add favorite recipe info with v0 expect correct sequence`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = true) subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = true)
coVerify { coVerify {
v0Source.requestUserInfo() dataSource.requestUserInfo()
v0Source.addFavoriteRecipe(eq(3), eq("cake")) dataSource.addFavoriteRecipe(eq("userId"), eq("cake"))
} }
} }
@Test @Test
fun `when add favorite recipe info with v1 expect correct sequence`() = runTest { fun `when get favorite recipes expect correct call`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = true)
coVerify {
v1Source.requestUserInfo()
v1Source.addFavoriteRecipe(eq("userId"), eq("cake"))
}
}
@Test
fun `when get favorite recipes with v1 expect correct call`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
subject.getFavoriteRecipes() subject.getFavoriteRecipes()
coVerify { v1Source.requestUserInfo() } coVerify { dataSource.requestUserInfo() }
} }
@Test @Test
fun `when get favorite recipes with v0 expect correct call`() = runTest { fun `when get favorite recipes expect correct result`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
subject.getFavoriteRecipes()
coVerify { v0Source.requestUserInfo() }
}
@Test
fun `when get favorite recipes with v1 expect correct result`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
assertThat(subject.getFavoriteRecipes()).isEqualTo(FAVORITE_RECIPES_LIST)
}
@Test
fun `when get favorite recipes with v0 expect correct result`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
assertThat(subject.getFavoriteRecipes()).isEqualTo(FAVORITE_RECIPES_LIST) assertThat(subject.getFavoriteRecipes()).isEqualTo(FAVORITE_RECIPES_LIST)
} }
} }

View File

@@ -1,27 +1,26 @@
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.baseurl.ServerInfoRepo import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.data.baseurl.ServerInfoStorage
import io.mockk.coEvery import gq.kirmanak.mealient.test.HiltRobolectricTest
import io.mockk.impl.annotations.MockK import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest
class RecipeImageUrlProviderImplTest : BaseUnitTest() { class RecipeImageUrlProviderImplTest : HiltRobolectricTest() {
@Inject
lateinit var subject: RecipeImageUrlProvider lateinit var subject: RecipeImageUrlProvider
@MockK @Inject
lateinit var serverInfoRepo: ServerInfoRepo lateinit var serverInfoStorage: ServerInfoStorage
@Before @Before
override fun setUp() { fun setUp() {
super.setUp()
subject = RecipeImageUrlProviderImpl(serverInfoRepo, logger)
prepareBaseURL("https://google.com/") prepareBaseURL("https://google.com/")
} }
@@ -76,7 +75,7 @@ class RecipeImageUrlProviderImplTest : BaseUnitTest() {
assertThat(actual).isNull() assertThat(actual).isNull()
} }
private fun prepareBaseURL(baseURL: String?) { private fun prepareBaseURL(baseURL: String?) = runBlocking {
coEvery { serverInfoRepo.getUrl() } returns baseURL serverInfoStorage.storeBaseURL(baseURL)
} }
} }

View File

@@ -9,13 +9,11 @@ import gq.kirmanak.mealient.database.TEST_RECIPE_SUMMARY_ENTITIES
import gq.kirmanak.mealient.database.recipe.RecipeStorage import gq.kirmanak.mealient.database.recipe.RecipeStorage
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.test.HiltRobolectricTest import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import javax.inject.Inject import javax.inject.Inject
@HiltAndroidTest @HiltAndroidTest
@OptIn(ExperimentalCoroutinesApi::class)
class RecipePagingSourceFactoryImplTest : HiltRobolectricTest() { class RecipePagingSourceFactoryImplTest : HiltRobolectricTest() {
@Inject @Inject

View File

@@ -13,7 +13,7 @@ import gq.kirmanak.mealient.database.FULL_CAKE_INFO_ENTITY
import gq.kirmanak.mealient.database.MIX_CAKE_RECIPE_INSTRUCTION_ENTITY import gq.kirmanak.mealient.database.MIX_CAKE_RECIPE_INSTRUCTION_ENTITY
import gq.kirmanak.mealient.database.recipe.RecipeStorage import gq.kirmanak.mealient.database.recipe.RecipeStorage
import gq.kirmanak.mealient.datasource.NetworkError.Unauthorized import gq.kirmanak.mealient.datasource.NetworkError.Unauthorized
import gq.kirmanak.mealient.datasource_test.CAKE_FULL_RECIPE_INFO import gq.kirmanak.mealient.datasource_test.CAKE_RECIPE_RESPONSE
import gq.kirmanak.mealient.model_mapper.ModelMapper import gq.kirmanak.mealient.model_mapper.ModelMapper
import gq.kirmanak.mealient.model_mapper.ModelMapperImpl import gq.kirmanak.mealient.model_mapper.ModelMapperImpl
import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.BaseUnitTest
@@ -22,13 +22,11 @@ import io.mockk.coVerify
import io.mockk.coVerifyOrder import io.mockk.coVerifyOrder
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import java.io.IOException import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class RecipeRepoTest : BaseUnitTest() { class RecipeRepoTest : BaseUnitTest() {
@MockK(relaxUnitFun = true) @MockK(relaxUnitFun = true)
@@ -69,7 +67,7 @@ class RecipeRepoTest : BaseUnitTest() {
@Test @Test
fun `when refreshRecipeInfo expect call to storage`() = runTest { fun `when refreshRecipeInfo expect call to storage`() = runTest {
coEvery { dataSource.requestRecipeInfo(eq("cake")) } returns CAKE_FULL_RECIPE_INFO coEvery { dataSource.requestRecipe(eq("cake")) } returns CAKE_RECIPE_RESPONSE
subject.refreshRecipeInfo("cake") subject.refreshRecipeInfo("cake")
coVerify { coVerify {
storage.saveRecipeInfo( storage.saveRecipeInfo(

View File

@@ -16,13 +16,11 @@ import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import java.io.IOException import java.io.IOException
@ExperimentalCoroutinesApi
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
class RecipesRemoteMediatorTest : BaseUnitTest() { class RecipesRemoteMediatorTest : BaseUnitTest() {

View File

@@ -1,15 +1,13 @@
package gq.kirmanak.mealient.data.share package gq.kirmanak.mealient.data.share
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLInfo import gq.kirmanak.mealient.datasource.models.ParseRecipeURLRequest
import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ShareRecipeRepoImplTest : BaseUnitTest() { class ShareRecipeRepoImplTest : BaseUnitTest() {
@@ -32,7 +30,7 @@ class ShareRecipeRepoImplTest : BaseUnitTest() {
@Test @Test
fun `when url is correct expect saveRecipeByURL saves it`() = runTest { fun `when url is correct expect saveRecipeByURL saves it`() = runTest {
subject.saveRecipeByURL("https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/") subject.saveRecipeByURL("https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/")
val expected = ParseRecipeURLInfo( val expected = ParseRecipeURLRequest(
url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/", url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/",
includeTags = true includeTags = true
) )
@@ -42,7 +40,7 @@ class ShareRecipeRepoImplTest : BaseUnitTest() {
@Test @Test
fun `when url has prefix expect saveRecipeByURL removes it`() = runTest { fun `when url has prefix expect saveRecipeByURL removes it`() = runTest {
subject.saveRecipeByURL("My favorite recipe: https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/") subject.saveRecipeByURL("My favorite recipe: https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/")
val expected = ParseRecipeURLInfo( val expected = ParseRecipeURLRequest(
url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/", url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/",
includeTags = true includeTags = true
) )
@@ -52,7 +50,7 @@ class ShareRecipeRepoImplTest : BaseUnitTest() {
@Test @Test
fun `when url has suffix expect saveRecipeByURL removes it`() = runTest { fun `when url has suffix expect saveRecipeByURL removes it`() = runTest {
subject.saveRecipeByURL("https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/ is my favorite recipe") subject.saveRecipeByURL("https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/ is my favorite recipe")
val expected = ParseRecipeURLInfo( val expected = ParseRecipeURLRequest(
url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie", url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie",
includeTags = true includeTags = true
) )
@@ -62,7 +60,7 @@ class ShareRecipeRepoImplTest : BaseUnitTest() {
@Test @Test
fun `when url has prefix and suffix expect saveRecipeByURL removes them`() = runTest { fun `when url has prefix and suffix expect saveRecipeByURL removes them`() = runTest {
subject.saveRecipeByURL("Actually, https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/ is my favorite recipe") subject.saveRecipeByURL("Actually, https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/ is my favorite recipe")
val expected = ParseRecipeURLInfo( val expected = ParseRecipeURLRequest(
url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie", url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie",
includeTags = true includeTags = true
) )

View File

@@ -3,13 +3,11 @@ package gq.kirmanak.mealient.data.storage
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.HiltRobolectricTest import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import javax.inject.Inject import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest @HiltAndroidTest
class PreferencesStorageImplTest : HiltRobolectricTest() { class PreferencesStorageImplTest : HiltRobolectricTest() {

View File

@@ -1,22 +1,14 @@
package gq.kirmanak.mealient.test package gq.kirmanak.mealient.test
import gq.kirmanak.mealient.data.baseurl.ServerVersion import gq.kirmanak.mealient.datasource.models.GetUserInfoResponse
import gq.kirmanak.mealient.datasource.v0.models.GetUserInfoResponseV0
import gq.kirmanak.mealient.datasource.v1.models.GetUserInfoResponseV1
object AuthImplTestData { object AuthImplTestData {
const val TEST_USERNAME = "TEST_USERNAME" const val TEST_USERNAME = "TEST_USERNAME"
const val TEST_PASSWORD = "TEST_PASSWORD" const val TEST_PASSWORD = "TEST_PASSWORD"
const val TEST_BASE_URL = "https://example.com" const val TEST_BASE_URL = "https://example.com"
const val TEST_TOKEN = "TEST_TOKEN" const val TEST_TOKEN = "TEST_TOKEN"
const val TEST_AUTH_HEADER = "Bearer TEST_TOKEN"
const val TEST_API_TOKEN = "TEST_API_TOKEN" const val TEST_API_TOKEN = "TEST_API_TOKEN"
const val TEST_API_AUTH_HEADER = "Bearer TEST_API_TOKEN"
const val TEST_VERSION = "v0.5.6"
val TEST_SERVER_VERSION_V0 = ServerVersion.V0
val TEST_SERVER_VERSION_V1 = ServerVersion.V1
val FAVORITE_RECIPES_LIST = listOf("cake", "porridge") val FAVORITE_RECIPES_LIST = listOf("cake", "porridge")
val USER_INFO_V1 = GetUserInfoResponseV1("userId", FAVORITE_RECIPES_LIST) val USER_INFO = GetUserInfoResponse("userId", FAVORITE_RECIPES_LIST)
val USER_INFO_V0 = GetUserInfoResponseV0(3, FAVORITE_RECIPES_LIST)
} }

View File

@@ -45,7 +45,6 @@ class MainActivityViewModelTest : BaseUnitTest() {
every { activityUiStateController.getUiStateFlow() } returns MutableStateFlow( every { activityUiStateController.getUiStateFlow() } returns MutableStateFlow(
ActivityUiState() ActivityUiState()
) )
coEvery { serverInfoRepo.versionUpdates() } returns emptyFlow()
subject = MainActivityViewModel( subject = MainActivityViewModel(
authRepo = authRepo, authRepo = authRepo,
logger = logger, logger = logger,

View File

@@ -7,7 +7,6 @@ import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@@ -16,7 +15,6 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AddRecipeViewModelTest : BaseUnitTest() { class AddRecipeViewModelTest : BaseUnitTest() {
@MockK(relaxUnitFun = true) @MockK(relaxUnitFun = true)

View File

@@ -7,12 +7,10 @@ import gq.kirmanak.mealient.database.FULL_CAKE_INFO_ENTITY
import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class RecipeInfoViewModelTest : BaseUnitTest() { class RecipeInfoViewModelTest : BaseUnitTest() {
@MockK @MockK

View File

@@ -9,7 +9,6 @@ import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
@@ -17,7 +16,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ShareRecipeViewModelTest : BaseUnitTest() { class ShareRecipeViewModelTest : BaseUnitTest() {
@MockK(relaxUnitFun = true) @MockK(relaxUnitFun = true)

View File

@@ -2,13 +2,11 @@ package gq.kirmanak.mealient.architecture
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.BaseUnitTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class FlowExtensionsKtTest : BaseUnitTest() { class FlowExtensionsKtTest : BaseUnitTest() {
@Test @Test

View File

@@ -5,13 +5,11 @@ import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.database.recipe.RecipeDao import gq.kirmanak.mealient.database.recipe.RecipeDao
import gq.kirmanak.mealient.database.recipe.RecipeStorageImpl import gq.kirmanak.mealient.database.recipe.RecipeStorageImpl
import gq.kirmanak.mealient.test.HiltRobolectricTest import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import javax.inject.Inject import javax.inject.Inject
@HiltAndroidTest @HiltAndroidTest
@OptIn(ExperimentalCoroutinesApi::class)
internal class RecipeStorageImplTest : HiltRobolectricTest() { internal class RecipeStorageImplTest : HiltRobolectricTest() {
@Inject @Inject

View File

@@ -15,7 +15,7 @@ val CAKE_RECIPE_SUMMARY_ENTITY = RecipeSummaryEntity(
description = "A tasty cake", description = "A tasty cake",
dateAdded = LocalDate.parse("2021-11-13"), dateAdded = LocalDate.parse("2021-11-13"),
dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13"), dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13"),
imageId = "cake", imageId = "1",
isFavorite = false, isFavorite = false,
) )
@@ -26,7 +26,7 @@ val PORRIDGE_RECIPE_SUMMARY_ENTITY = RecipeSummaryEntity(
description = "A tasty porridge", description = "A tasty porridge",
dateAdded = LocalDate.parse("2021-11-12"), dateAdded = LocalDate.parse("2021-11-12"),
dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"), dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"),
imageId = "porridge", imageId = "2",
isFavorite = false, isFavorite = false,
) )

View File

@@ -25,16 +25,19 @@ dependencies {
implementation(libs.jetbrains.kotlinx.serialization) implementation(libs.jetbrains.kotlinx.serialization)
implementation(libs.squareup.retrofit) implementation(libs.jetbrains.kotlinx.coroutinesAndroid)
testImplementation(libs.jetbrains.kotlinx.coroutinesTest)
implementation(libs.jakewharton.retrofitSerialization)
implementation(platform(libs.okhttp3.bom)) implementation(platform(libs.okhttp3.bom))
implementation(libs.okhttp3.okhttp) implementation(libs.okhttp3.okhttp)
debugImplementation(libs.okhttp3.loggingInterceptor) debugImplementation(libs.okhttp3.loggingInterceptor)
implementation(libs.jetbrains.kotlinx.coroutinesAndroid) implementation(libs.ktor.core)
testImplementation(libs.jetbrains.kotlinx.coroutinesTest) implementation(libs.ktor.auth)
implementation(libs.ktor.encoding)
implementation(libs.ktor.negotiation)
implementation(libs.ktor.json)
implementation(libs.ktor.okhttp)
testImplementation(libs.androidx.test.junit) testImplementation(libs.androidx.test.junit)

View File

@@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Needed to display Chucker notification --> <!-- Needed to display Chucker notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest> </manifest>

View File

@@ -17,7 +17,8 @@ import okhttp3.logging.HttpLoggingInterceptor
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object DebugModule { internal object DebugModule {
@Provides @Provides
@IntoSet @IntoSet
fun provideLoggingInterceptor(logger: Logger): Interceptor { fun provideLoggingInterceptor(logger: Logger): Interceptor {

View File

@@ -2,8 +2,7 @@ package gq.kirmanak.mealient.datasource
interface AuthenticationProvider { interface AuthenticationProvider {
suspend fun getAuthHeader(): String? suspend fun getAuthToken(): String?
suspend fun logout() suspend fun logout()
} }

View File

@@ -1,8 +0,0 @@
package gq.kirmanak.mealient.datasource
import okhttp3.Cache
interface CacheBuilder {
fun buildCache(): Cache
}

View File

@@ -1,10 +1,6 @@
package gq.kirmanak.mealient.datasource package gq.kirmanak.mealient.datasource
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import okhttp3.ResponseBody
/** /**
* Like [runCatching] but rethrows [CancellationException] to support * Like [runCatching] but rethrows [CancellationException] to support
@@ -18,9 +14,6 @@ inline fun <T> runCatchingExceptCancel(block: () -> T): Result<T> = try {
Result.failure(e) Result.failure(e)
} }
@OptIn(ExperimentalSerializationApi::class)
inline fun <reified R> ResponseBody.decode(json: Json): R = json.decodeFromStream(byteStream())
inline fun <reified T> Throwable.findCauseAsInstanceOf(): T? { inline fun <reified T> Throwable.findCauseAsInstanceOf(): T? {
var cause: Throwable? = this var cause: Throwable? = this
var previousCause: Throwable? = null var previousCause: Throwable? = null

View File

@@ -1,32 +1,17 @@
package gq.kirmanak.mealient.datasource package gq.kirmanak.mealient.datasource
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet import gq.kirmanak.mealient.datasource.impl.MealieDataSourceImpl
import gq.kirmanak.mealient.datasource.impl.AuthInterceptor import gq.kirmanak.mealient.datasource.impl.MealieServiceKtor
import gq.kirmanak.mealient.datasource.impl.BaseUrlInterceptor
import gq.kirmanak.mealient.datasource.impl.CacheBuilderImpl
import gq.kirmanak.mealient.datasource.impl.NetworkRequestWrapperImpl import gq.kirmanak.mealient.datasource.impl.NetworkRequestWrapperImpl
import gq.kirmanak.mealient.datasource.impl.OkHttpBuilderImpl import gq.kirmanak.mealient.datasource.impl.OkHttpBuilderImpl
import gq.kirmanak.mealient.datasource.impl.RetrofitBuilder
import gq.kirmanak.mealient.datasource.impl.TrustedCertificatesStoreImpl import gq.kirmanak.mealient.datasource.impl.TrustedCertificatesStoreImpl
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0Impl
import gq.kirmanak.mealient.datasource.v0.MealieServiceV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1Impl
import gq.kirmanak.mealient.datasource.v1.MealieServiceV1
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Converter
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -43,59 +28,22 @@ internal interface DataSourceModule {
encodeDefaults = true encodeDefaults = true
} }
@OptIn(ExperimentalSerializationApi::class)
@Provides @Provides
@Singleton @Singleton
fun provideConverterFactory(json: Json): Converter.Factory = fun provideOkHttp(okHttpBuilder: OkHttpBuilderImpl): OkHttpClient =
json.asConverterFactory("application/json".toMediaType())
@Provides
@Singleton
fun provideOkHttp(okHttpBuilder: OkHttpBuilder): OkHttpClient =
okHttpBuilder.buildOkHttp() okHttpBuilder.buildOkHttp()
@Provides
@Singleton
fun provideRetrofit(retrofitBuilder: RetrofitBuilder): Retrofit {
// Fake base URL which will be replaced later by BaseUrlInterceptor
// Solution was suggested here https://github.com/square/retrofit/issues/2161#issuecomment-274204152
return retrofitBuilder.buildRetrofit("http://localhost/")
}
@Provides
@Singleton
fun provideMealieService(retrofit: Retrofit): MealieServiceV0 =
retrofit.create()
@Provides
@Singleton
fun provideMealieServiceV1(retrofit: Retrofit): MealieServiceV1 =
retrofit.create()
} }
@Binds @Binds
fun bindCacheBuilder(cacheBuilderImpl: CacheBuilderImpl): CacheBuilder fun bindMealieDataSource(mealientDataSourceImpl: MealieDataSourceImpl): MealieDataSource
@Binds @Binds
fun bindOkHttpBuilder(okHttpBuilderImpl: OkHttpBuilderImpl): OkHttpBuilder fun bindMealieService(impl: MealieServiceKtor): MealieService
@Binds
fun bindMealieDataSource(mealientDataSourceImpl: MealieDataSourceV0Impl): MealieDataSourceV0
@Binds
fun bindMealieDataSourceV1(mealientDataSourceImpl: MealieDataSourceV1Impl): MealieDataSourceV1
@Binds @Binds
fun bindNetworkRequestWrapper(networkRequestWrapperImpl: NetworkRequestWrapperImpl): NetworkRequestWrapper fun bindNetworkRequestWrapper(networkRequestWrapperImpl: NetworkRequestWrapperImpl): NetworkRequestWrapper
@Binds
@IntoSet
fun bindAuthInterceptor(authInterceptor: AuthInterceptor): LocalInterceptor
@Binds
@IntoSet
fun bindBaseUrlInterceptor(baseUrlInterceptor: BaseUrlInterceptor): LocalInterceptor
@Binds @Binds
fun bindTrustedCertificatesStore(impl: TrustedCertificatesStoreImpl): TrustedCertificatesStore fun bindTrustedCertificatesStore(impl: TrustedCertificatesStoreImpl): TrustedCertificatesStore
} }

View File

@@ -1,12 +0,0 @@
package gq.kirmanak.mealient.datasource
import okhttp3.Interceptor
import okhttp3.OkHttpClient
/**
* Marker interface which is different from [Interceptor] only in how it is handled.
* [Interceptor]s are added as network interceptors to OkHttpClient whereas [LocalInterceptor]s
* are added via [OkHttpClient.Builder.addInterceptor] function. They will observe the
* full call lifecycle, whereas network interceptors will see only the network part.
*/
interface LocalInterceptor : Interceptor

View File

@@ -0,0 +1,78 @@
package gq.kirmanak.mealient.datasource
import gq.kirmanak.mealient.datasource.models.CreateApiTokenRequest
import gq.kirmanak.mealient.datasource.models.CreateApiTokenResponse
import gq.kirmanak.mealient.datasource.models.CreateRecipeRequest
import gq.kirmanak.mealient.datasource.models.CreateShoppingListItemRequest
import gq.kirmanak.mealient.datasource.models.GetFoodsResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse
import gq.kirmanak.mealient.datasource.models.GetShoppingListResponse
import gq.kirmanak.mealient.datasource.models.GetShoppingListsResponse
import gq.kirmanak.mealient.datasource.models.GetUnitsResponse
import gq.kirmanak.mealient.datasource.models.GetUserInfoResponse
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLRequest
import gq.kirmanak.mealient.datasource.models.UpdateRecipeRequest
import gq.kirmanak.mealient.datasource.models.VersionResponse
interface MealieDataSource {
suspend fun createRecipe(
recipe: CreateRecipeRequest,
): String
suspend fun updateRecipe(
slug: String,
recipe: UpdateRecipeRequest,
): GetRecipeResponse
/**
* Tries to acquire authentication token using the provided credentials
*/
suspend fun authenticate(
username: String,
password: String,
): String
suspend fun getVersionInfo(): VersionResponse
suspend fun requestRecipes(
page: Int,
perPage: Int,
): List<GetRecipeSummaryResponse>
suspend fun requestRecipeInfo(
slug: String,
): GetRecipeResponse
suspend fun parseRecipeFromURL(
request: ParseRecipeURLRequest,
): String
suspend fun createApiToken(
request: CreateApiTokenRequest,
): CreateApiTokenResponse
suspend fun requestUserInfo(): GetUserInfoResponse
suspend fun removeFavoriteRecipe(userId: String, recipeSlug: String)
suspend fun addFavoriteRecipe(userId: String, recipeSlug: String)
suspend fun deleteRecipe(slug: String)
suspend fun getShoppingLists(page: Int, perPage: Int): GetShoppingListsResponse
suspend fun getShoppingList(id: String): GetShoppingListResponse
suspend fun deleteShoppingListItem(id: String)
suspend fun updateShoppingListItem(item: GetShoppingListItemResponse)
suspend fun getFoods(): GetFoodsResponse
suspend fun getUnits(): GetUnitsResponse
suspend fun addShoppingListItem(request: CreateShoppingListItemRequest)
}

View File

@@ -0,0 +1,64 @@
package gq.kirmanak.mealient.datasource
import gq.kirmanak.mealient.datasource.models.CreateApiTokenRequest
import gq.kirmanak.mealient.datasource.models.CreateApiTokenResponse
import gq.kirmanak.mealient.datasource.models.CreateRecipeRequest
import gq.kirmanak.mealient.datasource.models.CreateShoppingListItemRequest
import gq.kirmanak.mealient.datasource.models.GetFoodsResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipesResponse
import gq.kirmanak.mealient.datasource.models.GetShoppingListResponse
import gq.kirmanak.mealient.datasource.models.GetShoppingListsResponse
import gq.kirmanak.mealient.datasource.models.GetTokenResponse
import gq.kirmanak.mealient.datasource.models.GetUnitsResponse
import gq.kirmanak.mealient.datasource.models.GetUserInfoResponse
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLRequest
import gq.kirmanak.mealient.datasource.models.UpdateRecipeRequest
import gq.kirmanak.mealient.datasource.models.VersionResponse
import kotlinx.serialization.json.JsonElement
internal interface MealieService {
suspend fun getToken(username: String, password: String): GetTokenResponse
suspend fun createRecipe(addRecipeRequest: CreateRecipeRequest): String
suspend fun updateRecipe(
addRecipeRequest: UpdateRecipeRequest,
slug: String,
): GetRecipeResponse
suspend fun getVersion(): VersionResponse
suspend fun getRecipeSummary(page: Int, perPage: Int): GetRecipesResponse
suspend fun getRecipe(slug: String): GetRecipeResponse
suspend fun createRecipeFromURL(request: ParseRecipeURLRequest): String
suspend fun createApiToken(request: CreateApiTokenRequest): CreateApiTokenResponse
suspend fun getUserSelfInfo(): GetUserInfoResponse
suspend fun removeFavoriteRecipe(userId: String, recipeSlug: String)
suspend fun addFavoriteRecipe(userId: String, recipeSlug: String)
suspend fun deleteRecipe(slug: String)
suspend fun getShoppingLists(page: Int, perPage: Int): GetShoppingListsResponse
suspend fun getShoppingList(id: String): GetShoppingListResponse
suspend fun getShoppingListItem(id: String): JsonElement
suspend fun updateShoppingListItem(id: String, request: JsonElement)
suspend fun deleteShoppingListItem(id: String)
suspend fun getFoods(perPage: Int): GetFoodsResponse
suspend fun getUnits(perPage: Int): GetUnitsResponse
suspend fun createShoppingListItem(request: CreateShoppingListItemRequest)
}

View File

@@ -1,8 +0,0 @@
package gq.kirmanak.mealient.datasource
import okhttp3.OkHttpClient
interface OkHttpBuilder {
fun buildOkHttp(): OkHttpClient
}

View File

@@ -0,0 +1,6 @@
package gq.kirmanak.mealient.datasource
interface SignOutHandler {
fun signOut()
}

View File

@@ -1,42 +0,0 @@
package gq.kirmanak.mealient.datasource.impl
import androidx.annotation.VisibleForTesting
import gq.kirmanak.mealient.datasource.AuthenticationProvider
import gq.kirmanak.mealient.datasource.LocalInterceptor
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Provider
internal class AuthInterceptor @Inject constructor(
private val logger: Logger,
private val authenticationProviderProvider: Provider<AuthenticationProvider>,
) : LocalInterceptor {
private val authenticationProvider: AuthenticationProvider
get() = authenticationProviderProvider.get()
override fun intercept(chain: Interceptor.Chain): Response {
logger.v { "intercept() was called with: request = ${chain.request()}" }
val header = getAuthHeader()
val request = chain.request().let {
if (header == null) it else it.newBuilder().header(HEADER_NAME, header).build()
}
logger.d { "Sending header $HEADER_NAME=${request.header(HEADER_NAME)}" }
return chain.proceed(request).also {
logger.v { "Response code is ${it.code}" }
if (it.code == 401 && header != null) logout()
}
}
private fun getAuthHeader() = runBlocking { authenticationProvider.getAuthHeader() }
private fun logout() = runBlocking { authenticationProvider.logout() }
companion object {
@VisibleForTesting
const val HEADER_NAME = "Authorization"
}
}

View File

@@ -1,46 +0,0 @@
package gq.kirmanak.mealient.datasource.impl
import gq.kirmanak.mealient.datasource.LocalInterceptor
import gq.kirmanak.mealient.datasource.ServerUrlProvider
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import javax.inject.Inject
import javax.inject.Provider
internal class BaseUrlInterceptor @Inject constructor(
private val logger: Logger,
private val serverUrlProviderProvider: Provider<ServerUrlProvider>,
) : LocalInterceptor {
private val serverUrlProvider: ServerUrlProvider
get() = serverUrlProviderProvider.get()
override fun intercept(chain: Interceptor.Chain): Response {
logger.v { "intercept() was called with: request = ${chain.request()}" }
val oldRequest = chain.request()
val baseUrl = getBaseUrl()
val correctUrl = oldRequest.url
.newBuilder()
.host(baseUrl.host)
.scheme(baseUrl.scheme)
.port(baseUrl.port)
.build()
val newRequest = oldRequest.newBuilder().url(correctUrl).build()
logger.d { "Replaced ${oldRequest.url} with ${newRequest.url}" }
return chain.proceed(newRequest)
}
private fun getBaseUrl() = runBlocking {
val url = serverUrlProvider.getUrl() ?: throw IOException("Base URL is unknown")
url.runCatching {
toHttpUrl()
}.fold(
onSuccess = { it },
onFailure = { throw IOException(it.message, it) },
)
}
}

View File

@@ -3,7 +3,6 @@ package gq.kirmanak.mealient.datasource.impl
import android.content.Context import android.content.Context
import android.os.StatFs import android.os.StatFs
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import gq.kirmanak.mealient.datasource.CacheBuilder
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import okhttp3.Cache import okhttp3.Cache
import java.io.File import java.io.File
@@ -12,9 +11,9 @@ import javax.inject.Inject
internal class CacheBuilderImpl @Inject constructor( internal class CacheBuilderImpl @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val logger: Logger, private val logger: Logger,
) : CacheBuilder { ) {
override fun buildCache(): Cache { fun buildCache(): Cache {
val dir = findCacheDir() val dir = findCacheDir()
return Cache(dir, calculateDiskCacheSize(dir)) return Cache(dir, calculateDiskCacheSize(dir))
} }

View File

@@ -1,53 +1,53 @@
package gq.kirmanak.mealient.datasource.v1 package gq.kirmanak.mealient.datasource.impl
import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.datasource.MealieService
import gq.kirmanak.mealient.datasource.NetworkError import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.NetworkRequestWrapper import gq.kirmanak.mealient.datasource.NetworkRequestWrapper
import gq.kirmanak.mealient.datasource.decode import gq.kirmanak.mealient.datasource.models.CreateApiTokenRequest
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo import gq.kirmanak.mealient.datasource.models.CreateApiTokenResponse
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenRequestV1 import gq.kirmanak.mealient.datasource.models.CreateRecipeRequest
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenResponseV1 import gq.kirmanak.mealient.datasource.models.CreateShoppingListItemRequest
import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1 import gq.kirmanak.mealient.datasource.models.ErrorDetail
import gq.kirmanak.mealient.datasource.v1.models.CreateShoppingListItemRequestV1 import gq.kirmanak.mealient.datasource.models.GetFoodsResponse
import gq.kirmanak.mealient.datasource.v1.models.ErrorDetailV1 import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.v1.models.GetFoodsResponseV1 import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1 import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1 import gq.kirmanak.mealient.datasource.models.GetShoppingListResponse
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListResponseV1 import gq.kirmanak.mealient.datasource.models.GetShoppingListsResponse
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListsResponseV1 import gq.kirmanak.mealient.datasource.models.GetUnitsResponse
import gq.kirmanak.mealient.datasource.v1.models.GetUnitsResponseV1 import gq.kirmanak.mealient.datasource.models.GetUserInfoResponse
import gq.kirmanak.mealient.datasource.v1.models.GetUserInfoResponseV1 import gq.kirmanak.mealient.datasource.models.ParseRecipeURLRequest
import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1 import gq.kirmanak.mealient.datasource.models.UpdateRecipeRequest
import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1 import gq.kirmanak.mealient.datasource.models.VersionResponse
import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1 import io.ktor.client.call.NoTransformationFoundException
import kotlinx.serialization.SerializationException import io.ktor.client.call.body
import kotlinx.serialization.json.Json import io.ktor.client.plugins.ResponseException
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import retrofit2.HttpException import java.net.SocketException
import java.net.ConnectException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import javax.inject.Inject import javax.inject.Inject
class MealieDataSourceV1Impl @Inject constructor( internal class MealieDataSourceImpl @Inject constructor(
private val networkRequestWrapper: NetworkRequestWrapper, private val networkRequestWrapper: NetworkRequestWrapper,
private val service: MealieServiceV1, private val service: MealieService,
private val json: Json, ) : MealieDataSource {
) : MealieDataSourceV1 {
override suspend fun createRecipe( override suspend fun createRecipe(
recipe: CreateRecipeRequestV1 recipe: CreateRecipeRequest
): String = networkRequestWrapper.makeCallAndHandleUnauthorized( ): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipe(recipe) }, block = { service.createRecipe(recipe) },
logMethod = { "createRecipe" }, logMethod = { "createRecipe" },
logParameters = { "recipe = $recipe" } logParameters = { "recipe = $recipe" }
) ).trim('"')
override suspend fun updateRecipe( override suspend fun updateRecipe(
slug: String, slug: String,
recipe: UpdateRecipeRequestV1 recipe: UpdateRecipeRequest
): GetRecipeResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized( ): GetRecipeResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateRecipe(recipe, slug) }, block = { service.updateRecipe(recipe, slug) },
logMethod = { "updateRecipe" }, logMethod = { "updateRecipe" },
logParameters = { "slug = $slug, recipe = $recipe" } logParameters = { "slug = $slug, recipe = $recipe" }
@@ -61,18 +61,17 @@ class MealieDataSourceV1Impl @Inject constructor(
logMethod = { "authenticate" }, logMethod = { "authenticate" },
logParameters = { "username = $username, password = $password" } logParameters = { "username = $username, password = $password" }
).map { it.accessToken }.getOrElse { ).map { it.accessToken }.getOrElse {
val errorBody = (it as? HttpException)?.response()?.errorBody() ?: throw it val errorDetail = (it as? ResponseException)?.response?.body<ErrorDetail>() ?: throw it
val errorDetailV0 = errorBody.decode<ErrorDetailV1>(json) throw if (errorDetail.detail == "Unauthorized") NetworkError.Unauthorized(it) else it
throw if (errorDetailV0.detail == "Unauthorized") NetworkError.Unauthorized(it) else it
} }
override suspend fun getVersionInfo(): VersionResponseV1 = networkRequestWrapper.makeCall( override suspend fun getVersionInfo(): VersionResponse = networkRequestWrapper.makeCall(
block = { service.getVersion() }, block = { service.getVersion() },
logMethod = { "getVersionInfo" }, logMethod = { "getVersionInfo" },
).getOrElse { ).getOrElse {
throw when (it) { throw when (it) {
is HttpException, is SerializationException -> NetworkError.NotMealie(it) is ResponseException, is NoTransformationFoundException -> NetworkError.NotMealie(it)
is SocketTimeoutException, is ConnectException -> NetworkError.NoServerConnection(it) is SocketTimeoutException, is SocketException -> NetworkError.NoServerConnection(it)
else -> NetworkError.MalformedUrl(it) else -> NetworkError.MalformedUrl(it)
} }
} }
@@ -80,7 +79,7 @@ class MealieDataSourceV1Impl @Inject constructor(
override suspend fun requestRecipes( override suspend fun requestRecipes(
page: Int, page: Int,
perPage: Int perPage: Int
): List<GetRecipeSummaryResponseV1> = networkRequestWrapper.makeCallAndHandleUnauthorized( ): List<GetRecipeSummaryResponse> = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipeSummary(page, perPage) }, block = { service.getRecipeSummary(page, perPage) },
logMethod = { "requestRecipes" }, logMethod = { "requestRecipes" },
logParameters = { "page = $page, perPage = $perPage" } logParameters = { "page = $page, perPage = $perPage" }
@@ -88,14 +87,14 @@ class MealieDataSourceV1Impl @Inject constructor(
override suspend fun requestRecipeInfo( override suspend fun requestRecipeInfo(
slug: String slug: String
): GetRecipeResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized( ): GetRecipeResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipe(slug) }, block = { service.getRecipe(slug) },
logMethod = { "requestRecipeInfo" }, logMethod = { "requestRecipeInfo" },
logParameters = { "slug = $slug" } logParameters = { "slug = $slug" }
) )
override suspend fun parseRecipeFromURL( override suspend fun parseRecipeFromURL(
request: ParseRecipeURLRequestV1 request: ParseRecipeURLRequest
): String = networkRequestWrapper.makeCallAndHandleUnauthorized( ): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipeFromURL(request) }, block = { service.createRecipeFromURL(request) },
logMethod = { "parseRecipeFromURL" }, logMethod = { "parseRecipeFromURL" },
@@ -103,14 +102,14 @@ class MealieDataSourceV1Impl @Inject constructor(
) )
override suspend fun createApiToken( override suspend fun createApiToken(
request: CreateApiTokenRequestV1 request: CreateApiTokenRequest
): CreateApiTokenResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized( ): CreateApiTokenResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createApiToken(request) }, block = { service.createApiToken(request) },
logMethod = { "createApiToken" }, logMethod = { "createApiToken" },
logParameters = { "request = $request" } logParameters = { "request = $request" }
) )
override suspend fun requestUserInfo(): GetUserInfoResponseV1 { override suspend fun requestUserInfo(): GetUserInfoResponse {
return networkRequestWrapper.makeCallAndHandleUnauthorized( return networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getUserSelfInfo() }, block = { service.getUserSelfInfo() },
logMethod = { "requestUserInfo" }, logMethod = { "requestUserInfo" },
@@ -146,7 +145,7 @@ class MealieDataSourceV1Impl @Inject constructor(
override suspend fun getShoppingLists( override suspend fun getShoppingLists(
page: Int, page: Int,
perPage: Int, perPage: Int,
): GetShoppingListsResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized( ): GetShoppingListsResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingLists(page, perPage) }, block = { service.getShoppingLists(page, perPage) },
logMethod = { "getShoppingLists" }, logMethod = { "getShoppingLists" },
logParameters = { "page = $page, perPage = $perPage" } logParameters = { "page = $page, perPage = $perPage" }
@@ -154,7 +153,7 @@ class MealieDataSourceV1Impl @Inject constructor(
override suspend fun getShoppingList( override suspend fun getShoppingList(
id: String id: String
): GetShoppingListResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized( ): GetShoppingListResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingList(id) }, block = { service.getShoppingList(id) },
logMethod = { "getShoppingList" }, logMethod = { "getShoppingList" },
logParameters = { "id = $id" } logParameters = { "id = $id" }
@@ -186,7 +185,7 @@ class MealieDataSourceV1Impl @Inject constructor(
) )
override suspend fun updateShoppingListItem( override suspend fun updateShoppingListItem(
item: ShoppingListItemInfo item: GetShoppingListItemResponse
) { ) {
// Has to be done in two steps because we can't specify only the changed fields // Has to be done in two steps because we can't specify only the changed fields
val remoteItem = getShoppingListItem(item.id) val remoteItem = getShoppingListItem(item.id)
@@ -203,14 +202,14 @@ class MealieDataSourceV1Impl @Inject constructor(
updateShoppingListItem(item.id, JsonObject(updatedItem)) updateShoppingListItem(item.id, JsonObject(updatedItem))
} }
override suspend fun getFoods(): GetFoodsResponseV1 { override suspend fun getFoods(): GetFoodsResponse {
return networkRequestWrapper.makeCallAndHandleUnauthorized( return networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getFoods(perPage = -1) }, block = { service.getFoods(perPage = -1) },
logMethod = { "getFoods" }, logMethod = { "getFoods" },
) )
} }
override suspend fun getUnits(): GetUnitsResponseV1 { override suspend fun getUnits(): GetUnitsResponse {
return networkRequestWrapper.makeCallAndHandleUnauthorized( return networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getUnits(perPage = -1) }, block = { service.getUnits(perPage = -1) },
logMethod = { "getUnits" }, logMethod = { "getUnits" },
@@ -218,7 +217,7 @@ class MealieDataSourceV1Impl @Inject constructor(
} }
override suspend fun addShoppingListItem( override suspend fun addShoppingListItem(
request: CreateShoppingListItemRequestV1 request: CreateShoppingListItemRequest
) = networkRequestWrapper.makeCallAndHandleUnauthorized( ) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createShoppingListItem(request) }, block = { service.createShoppingListItem(request) },
logMethod = { "addShoppingListItem" }, logMethod = { "addShoppingListItem" },

View File

@@ -0,0 +1,210 @@
package gq.kirmanak.mealient.datasource.impl
import gq.kirmanak.mealient.datasource.MealieService
import gq.kirmanak.mealient.datasource.ServerUrlProvider
import gq.kirmanak.mealient.datasource.models.CreateApiTokenRequest
import gq.kirmanak.mealient.datasource.models.CreateApiTokenResponse
import gq.kirmanak.mealient.datasource.models.CreateRecipeRequest
import gq.kirmanak.mealient.datasource.models.CreateShoppingListItemRequest
import gq.kirmanak.mealient.datasource.models.GetFoodsResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipesResponse
import gq.kirmanak.mealient.datasource.models.GetShoppingListResponse
import gq.kirmanak.mealient.datasource.models.GetShoppingListsResponse
import gq.kirmanak.mealient.datasource.models.GetTokenResponse
import gq.kirmanak.mealient.datasource.models.GetUnitsResponse
import gq.kirmanak.mealient.datasource.models.GetUserInfoResponse
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLRequest
import gq.kirmanak.mealient.datasource.models.UpdateRecipeRequest
import gq.kirmanak.mealient.datasource.models.VersionResponse
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.delete
import io.ktor.client.request.forms.FormDataContent
import io.ktor.client.request.get
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.URLBuilder
import io.ktor.http.contentType
import io.ktor.http.parameters
import io.ktor.http.path
import io.ktor.http.takeFrom
import kotlinx.serialization.json.JsonElement
import javax.inject.Inject
import javax.inject.Provider
internal class MealieServiceKtor @Inject constructor(
private val httpClient: HttpClient,
private val serverUrlProviderProvider: Provider<ServerUrlProvider>,
) : MealieService {
private val serverUrlProvider: ServerUrlProvider
get() = serverUrlProviderProvider.get()
override suspend fun getToken(username: String, password: String): GetTokenResponse {
val formParameters = parameters {
append("username", username)
append("password", password)
}
return httpClient.post {
endpoint("/api/auth/token")
setBody(FormDataContent(formParameters))
}.body()
}
override suspend fun createRecipe(addRecipeRequest: CreateRecipeRequest): String {
return httpClient.post {
endpoint("/api/recipes")
contentType(ContentType.Application.Json)
setBody(addRecipeRequest)
}.body()
}
override suspend fun updateRecipe(
addRecipeRequest: UpdateRecipeRequest,
slug: String,
): GetRecipeResponse {
return httpClient.patch {
endpoint("/api/recipes/$slug")
contentType(ContentType.Application.Json)
setBody(addRecipeRequest)
}.body()
}
override suspend fun getVersion(): VersionResponse {
return httpClient.get {
endpoint("/api/app/about")
}.body()
}
override suspend fun getRecipeSummary(page: Int, perPage: Int): GetRecipesResponse {
return httpClient.get {
endpoint("/api/recipes") {
parameters.append("page", page.toString())
parameters.append("perPage", perPage.toString())
}
}.body()
}
override suspend fun getRecipe(slug: String): GetRecipeResponse {
return httpClient.get {
endpoint("/api/recipes/$slug")
}.body()
}
override suspend fun createRecipeFromURL(request: ParseRecipeURLRequest): String {
return httpClient.post {
endpoint("/api/recipes/create-url")
contentType(ContentType.Application.Json)
setBody(request)
}.body()
}
override suspend fun createApiToken(request: CreateApiTokenRequest): CreateApiTokenResponse {
return httpClient.post {
endpoint("/api/users/api-tokens")
contentType(ContentType.Application.Json)
setBody(request)
}.body()
}
override suspend fun getUserSelfInfo(): GetUserInfoResponse {
return httpClient.get {
endpoint("/api/users/self")
}.body()
}
override suspend fun removeFavoriteRecipe(userId: String, recipeSlug: String) {
httpClient.delete {
endpoint("/api/users/$userId/favorites/$recipeSlug")
}
}
override suspend fun addFavoriteRecipe(userId: String, recipeSlug: String) {
httpClient.post {
endpoint("/api/users/$userId/favorites/$recipeSlug")
}
}
override suspend fun deleteRecipe(slug: String) {
httpClient.delete {
endpoint("/api/recipes/$slug")
}
}
override suspend fun getShoppingLists(page: Int, perPage: Int): GetShoppingListsResponse {
return httpClient.get {
endpoint("/api/groups/shopping/lists") {
parameters.append("page", page.toString())
parameters.append("perPage", perPage.toString())
}
}.body()
}
override suspend fun getShoppingList(id: String): GetShoppingListResponse {
return httpClient.get {
endpoint("/api/groups/shopping/lists/$id")
}.body()
}
override suspend fun getShoppingListItem(id: String): JsonElement {
return httpClient.get {
endpoint("/api/groups/shopping/items/$id")
}.body()
}
override suspend fun updateShoppingListItem(id: String, request: JsonElement) {
httpClient.put {
endpoint("/api/groups/shopping/items/$id")
contentType(ContentType.Application.Json)
setBody(request)
}
}
override suspend fun deleteShoppingListItem(id: String) {
httpClient.delete {
endpoint("/api/groups/shopping/items/$id")
}
}
override suspend fun getFoods(perPage: Int): GetFoodsResponse {
return httpClient.get {
endpoint("/api/foods") {
parameters.append("perPage", perPage.toString())
}
}.body()
}
override suspend fun getUnits(perPage: Int): GetUnitsResponse {
return httpClient.get {
endpoint("/api/units") {
parameters.append("perPage", perPage.toString())
}
}.body()
}
override suspend fun createShoppingListItem(request: CreateShoppingListItemRequest) {
httpClient.post {
endpoint("/api/groups/shopping/items")
contentType(ContentType.Application.Json)
setBody(request)
}
}
private suspend fun HttpRequestBuilder.endpoint(
path: String,
block: URLBuilder.() -> Unit = {}
) {
val baseUrl = checkNotNull(serverUrlProvider.getUrl()) { "Server URL is not set" }
url {
takeFrom(baseUrl)
path(path)
block()
}
}
}

View File

@@ -4,7 +4,7 @@ import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.NetworkRequestWrapper import gq.kirmanak.mealient.datasource.NetworkRequestWrapper
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import retrofit2.HttpException import io.ktor.client.plugins.ResponseException
import javax.inject.Inject import javax.inject.Inject
internal class NetworkRequestWrapperImpl @Inject constructor( internal class NetworkRequestWrapperImpl @Inject constructor(
@@ -49,7 +49,8 @@ internal class NetworkRequestWrapperImpl @Inject constructor(
logMethod: () -> String, logMethod: () -> String,
logParameters: (() -> String)? logParameters: (() -> String)?
): T = makeCall(block, logMethod, logParameters).getOrElse { ): T = makeCall(block, logMethod, logParameters).getOrElse {
throw if (it is HttpException && it.code() in listOf(401, 403)) { val code = (it as? ResponseException)?.response?.status?.value
throw if (code in listOf(401, 403)) {
NetworkError.Unauthorized(it) NetworkError.Unauthorized(it)
} else { } else {
it it

View File

@@ -1,55 +1,28 @@
package gq.kirmanak.mealient.datasource.impl package gq.kirmanak.mealient.datasource.impl
import gq.kirmanak.mealient.datasource.CacheBuilder
import gq.kirmanak.mealient.datasource.LocalInterceptor
import gq.kirmanak.mealient.datasource.OkHttpBuilder
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.TlsVersion
import javax.inject.Inject import javax.inject.Inject
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
internal class OkHttpBuilderImpl @Inject constructor( internal class OkHttpBuilderImpl @Inject constructor(
private val cacheBuilder: CacheBuilder, private val cacheBuilder: CacheBuilderImpl,
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382) // Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
private val interceptors: Set<@JvmSuppressWildcards Interceptor>, private val interceptors: Set<@JvmSuppressWildcards Interceptor>,
private val localInterceptors: Set<@JvmSuppressWildcards LocalInterceptor>,
private val advancedX509TrustManager: AdvancedX509TrustManager, private val advancedX509TrustManager: AdvancedX509TrustManager,
private val sslSocketFactoryFactory: SslSocketFactoryFactory,
private val logger: Logger, private val logger: Logger,
) : OkHttpBuilder { ) {
override fun buildOkHttp(): OkHttpClient { fun buildOkHttp(): OkHttpClient {
logger.v { "buildOkHttp() was called with cacheBuilder = $cacheBuilder, interceptors = $interceptors, localInterceptors = $localInterceptors" } logger.v { "buildOkHttp() was called with cacheBuilder = $cacheBuilder, interceptors = $interceptors" }
val sslContext = buildSSLContext() val sslSocketFactory = sslSocketFactoryFactory.create()
sslContext.init(null, arrayOf<TrustManager>(advancedX509TrustManager), null)
val sslSocketFactory = sslContext.socketFactory
return OkHttpClient.Builder().apply { return OkHttpClient.Builder().apply {
localInterceptors.forEach(::addInterceptor)
interceptors.forEach(::addNetworkInterceptor) interceptors.forEach(::addNetworkInterceptor)
sslSocketFactory(sslSocketFactory, advancedX509TrustManager) sslSocketFactory(sslSocketFactory, advancedX509TrustManager)
cache(cacheBuilder.buildCache()) cache(cacheBuilder.buildCache())
}.build() }.build()
} }
private fun buildSSLContext(): SSLContext {
return runCatching {
SSLContext.getInstance(TlsVersion.TLS_1_3.javaName)
}.recoverCatching {
logger.w { "TLSv1.3 is not supported in this device; falling through TLSv1.2" }
SSLContext.getInstance(TlsVersion.TLS_1_2.javaName)
}.recoverCatching {
logger.w { "TLSv1.2 is not supported in this device; falling through TLSv1.1" }
SSLContext.getInstance(TlsVersion.TLS_1_1.javaName)
}.recoverCatching {
logger.w { "TLSv1.1 is not supported in this device; falling through TLSv1.0" }
// should be available in any device; see reference of supported protocols in
// http://developer.android.com/reference/javax/net/ssl/SSLSocket.html
SSLContext.getInstance(TlsVersion.TLS_1_0.javaName)
}.getOrThrow()
}
} }

View File

@@ -1,23 +0,0 @@
package gq.kirmanak.mealient.datasource.impl
import gq.kirmanak.mealient.logging.Logger
import okhttp3.OkHttpClient
import retrofit2.Converter.Factory
import retrofit2.Retrofit
import javax.inject.Inject
internal class RetrofitBuilder @Inject constructor(
private val okHttpClient: OkHttpClient,
private val converterFactory: Factory,
private val logger: Logger,
) {
fun buildRetrofit(baseUrl: String): Retrofit {
logger.v { "buildRetrofit() called with: baseUrl = $baseUrl" }
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(converterFactory)
.build()
}
}

View File

@@ -0,0 +1,36 @@
package gq.kirmanak.mealient.datasource.impl
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
internal class SslSocketFactoryFactory @Inject constructor(
private val advancedX509TrustManager: AdvancedX509TrustManager,
private val logger: Logger,
) {
fun create(): SSLSocketFactory {
val sslContext = buildSSLContext()
sslContext.init(null, arrayOf<TrustManager>(advancedX509TrustManager), null)
return sslContext.socketFactory
}
private fun buildSSLContext(): SSLContext {
return runCatching {
SSLContext.getInstance("TLSv1.3")
}.recoverCatching {
logger.w { "TLSv1.3 is not supported in this device; falling through TLSv1.2" }
SSLContext.getInstance("TLSv1.2")
}.recoverCatching {
logger.w { "TLSv1.2 is not supported in this device; falling through TLSv1.1" }
SSLContext.getInstance("TLSv1.1")
}.recoverCatching {
logger.w { "TLSv1.1 is not supported in this device; falling through TLSv1.0" }
// should be available in any device; see reference of supported protocols in
// http://developer.android.com/reference/javax/net/ssl/SSLSocket.html
SSLContext.getInstance("TLSv1")
}.getOrThrow()
}
}

View File

@@ -0,0 +1,50 @@
package gq.kirmanak.mealient.datasource.ktor
import gq.kirmanak.mealient.datasource.AuthenticationProvider
import gq.kirmanak.mealient.logging.Logger
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.http.HttpStatusCode
import javax.inject.Inject
import javax.inject.Provider
internal class AuthKtorConfiguration @Inject constructor(
private val authenticationProviderProvider: Provider<AuthenticationProvider>,
private val logger: Logger,
) : KtorConfiguration {
private val authenticationProvider: AuthenticationProvider
get() = authenticationProviderProvider.get()
override fun <T : HttpClientEngineConfig> configure(config: HttpClientConfig<T>) {
config.install(Auth) {
bearer {
loadTokens {
getTokens()
}
refreshTokens {
val newTokens = getTokens()
val sameAccessToken = newTokens?.accessToken == oldTokens?.accessToken
if (sameAccessToken && response.status == HttpStatusCode.Unauthorized) {
authenticationProvider.logout()
null
} else {
newTokens
}
}
sendWithoutRequest { true }
}
}
}
private suspend fun getTokens(): BearerTokens? {
val token = authenticationProvider.getAuthToken()
logger.v { "getTokens(): token = $token" }
return token?.let { BearerTokens(accessToken = it, refreshToken = "") }
}
}

View File

@@ -0,0 +1,19 @@
package gq.kirmanak.mealient.datasource.ktor
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import javax.inject.Inject
internal class ContentNegotiationConfiguration @Inject constructor(
private val json: Json,
) : KtorConfiguration {
override fun <T : HttpClientEngineConfig> configure(config: HttpClientConfig<T>) {
config.install(ContentNegotiation) {
json(json)
}
}
}

View File

@@ -0,0 +1,17 @@
package gq.kirmanak.mealient.datasource.ktor
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.plugins.compression.ContentEncoding
import javax.inject.Inject
internal class EncodingKtorConfiguration @Inject constructor() : KtorConfiguration {
override fun <T : HttpClientEngineConfig> configure(config: HttpClientConfig<T>) {
config.install(ContentEncoding) {
gzip()
deflate()
identity()
}
}
}

View File

@@ -0,0 +1,8 @@
package gq.kirmanak.mealient.datasource.ktor
import io.ktor.client.HttpClient
internal interface KtorClientBuilder {
fun buildKtorClient(): HttpClient
}

View File

@@ -0,0 +1,32 @@
package gq.kirmanak.mealient.datasource.ktor
import gq.kirmanak.mealient.logging.Logger
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import okhttp3.OkHttpClient
import javax.inject.Inject
internal class KtorClientBuilderImpl @Inject constructor(
private val configurators: Set<@JvmSuppressWildcards KtorConfiguration>,
private val logger: Logger,
private val okHttpClient: OkHttpClient,
) : KtorClientBuilder {
override fun buildKtorClient(): HttpClient {
logger.v { "buildKtorClient() called" }
val client = HttpClient(OkHttp) {
expectSuccess = true
configurators.forEach {
it.configure(config = this)
}
engine {
preconfigured = okHttpClient
}
}
return client
}
}

View File

@@ -0,0 +1,9 @@
package gq.kirmanak.mealient.datasource.ktor
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.HttpClientEngineConfig
internal interface KtorConfiguration {
fun <T : HttpClientEngineConfig> configure(config: HttpClientConfig<T>)
}

View File

@@ -0,0 +1,41 @@
package gq.kirmanak.mealient.datasource.ktor
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.datasource.SignOutHandler
import io.ktor.client.HttpClient
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface KtorModule {
companion object {
@Provides
@Singleton
fun provideClient(builder: KtorClientBuilder): HttpClient = builder.buildKtorClient()
}
@Binds
@IntoSet
fun bindAuthKtorConfiguration(impl: AuthKtorConfiguration) : KtorConfiguration
@Binds
@IntoSet
fun bindEncodingKtorConfiguration(impl: EncodingKtorConfiguration) : KtorConfiguration
@Binds
@IntoSet
fun bindContentNegotiationConfiguration(impl: ContentNegotiationConfiguration) : KtorConfiguration
@Binds
fun bindKtorClientBuilder(impl: KtorClientBuilderImpl) : KtorClientBuilder
@Binds
fun bindSignOutHandler(impl: SignOutHandlerKtor) : SignOutHandler
}

View File

@@ -0,0 +1,20 @@
package gq.kirmanak.mealient.datasource.ktor
import gq.kirmanak.mealient.datasource.SignOutHandler
import io.ktor.client.HttpClient
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
import io.ktor.client.plugins.plugin
import javax.inject.Inject
internal class SignOutHandlerKtor @Inject constructor(
private val httpClient: HttpClient,
) : SignOutHandler {
override fun signOut() {
httpClient.plugin(Auth)
.providers
.filterIsInstance<BearerAuthProvider>()
.forEach { it.clearToken() }
}
}

View File

@@ -1,9 +1,9 @@
package gq.kirmanak.mealient.datasource.v1.models package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class CreateRecipeRequestV1( data class CreateApiTokenRequest(
@SerialName("name") val name: String, @SerialName("name") val name: String,
) )

View File

@@ -1,9 +1,9 @@
package gq.kirmanak.mealient.datasource.v1.models package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class CreateApiTokenResponseV1( data class CreateApiTokenResponse(
@SerialName("token") val token: String, @SerialName("token") val token: String,
) )

View File

@@ -1,9 +1,9 @@
package gq.kirmanak.mealient.datasource.v1.models package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class CreateApiTokenRequestV1( data class CreateRecipeRequest(
@SerialName("name") val name: String, @SerialName("name") val name: String,
) )

View File

@@ -1,10 +1,10 @@
package gq.kirmanak.mealient.datasource.v1.models package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class CreateShoppingListItemRequestV1( data class CreateShoppingListItemRequest(
@SerialName("shopping_list_id") val shoppingListId: String, @SerialName("shopping_list_id") val shoppingListId: String,
@SerialName("checked") val checked: Boolean, @SerialName("checked") val checked: Boolean,
@SerialName("position") val position: Int?, @SerialName("position") val position: Int?,

View File

@@ -1,9 +1,9 @@
package gq.kirmanak.mealient.datasource.v1.models package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class ErrorDetailV1( data class ErrorDetail(
@SerialName("detail") val detail: String? = null, @SerialName("detail") val detail: String? = null,
) )

View File

@@ -1,6 +0,0 @@
package gq.kirmanak.mealient.datasource.models
data class FoodInfo(
val name: String,
val id: String
)

View File

@@ -1,26 +0,0 @@
package gq.kirmanak.mealient.datasource.models
data class FullRecipeInfo(
val remoteId: String,
val name: String,
val recipeYield: String,
val recipeIngredients: List<RecipeIngredientInfo>,
val recipeInstructions: List<RecipeInstructionInfo>,
val settings: RecipeSettingsInfo,
)
data class RecipeSettingsInfo(
val disableAmounts: Boolean,
)
data class RecipeIngredientInfo(
val note: String,
val quantity: Double?,
val unit: String?,
val food: String?,
val title: String?,
)
data class RecipeInstructionInfo(
val text: String,
)

View File

@@ -1,28 +0,0 @@
package gq.kirmanak.mealient.datasource.models
data class FullShoppingListInfo(
val id: String,
val name: String,
val items: List<ShoppingListItemInfo>,
)
data class ShoppingListItemInfo(
val shoppingListId: String,
val id: String,
val checked: Boolean,
val position: Int,
val isFood: Boolean,
val note: String,
val quantity: Double,
val unit: UnitInfo?,
val food: FoodInfo?,
val recipeReferences: List<ShoppingListItemRecipeReferenceInfo>,
)
data class ShoppingListItemRecipeReferenceInfo(
val recipeId: String,
val recipeQuantity: Double,
val id: String,
val shoppingListId: String,
val recipe: FullRecipeInfo,
)

View File

@@ -1,15 +1,15 @@
package gq.kirmanak.mealient.datasource.v1.models package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class GetFoodsResponseV1( data class GetFoodsResponse(
@SerialName("items") val items: List<GetFoodResponseV1>, @SerialName("items") val items: List<GetFoodResponse>,
) )
@Serializable @Serializable
data class GetFoodResponseV1( data class GetFoodResponse(
@SerialName("name") val name: String, @SerialName("name") val name: String,
@SerialName("id") val id: String, @SerialName("id") val id: String,
) )

View File

@@ -1,33 +1,33 @@
package gq.kirmanak.mealient.datasource.v1.models package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class GetRecipeResponseV1( data class GetRecipeResponse(
@SerialName("id") val remoteId: String, @SerialName("id") val remoteId: String,
@SerialName("name") val name: String, @SerialName("name") val name: String,
@SerialName("recipeYield") val recipeYield: String = "", @SerialName("recipeYield") val recipeYield: String = "",
@SerialName("recipeIngredient") val recipeIngredients: List<GetRecipeIngredientResponseV1> = emptyList(), @SerialName("recipeIngredient") val recipeIngredients: List<GetRecipeIngredientResponse> = emptyList(),
@SerialName("recipeInstructions") val recipeInstructions: List<GetRecipeInstructionResponseV1> = emptyList(), @SerialName("recipeInstructions") val recipeInstructions: List<GetRecipeInstructionResponse> = emptyList(),
@SerialName("settings") val settings: GetRecipeSettingsResponseV1? = null, @SerialName("settings") val settings: GetRecipeSettingsResponse? = null,
) )
@Serializable @Serializable
data class GetRecipeSettingsResponseV1( data class GetRecipeSettingsResponse(
@SerialName("disableAmount") val disableAmount: Boolean, @SerialName("disableAmount") val disableAmount: Boolean,
) )
@Serializable @Serializable
data class GetRecipeIngredientResponseV1( data class GetRecipeIngredientResponse(
@SerialName("note") val note: String = "", @SerialName("note") val note: String = "",
@SerialName("unit") val unit: GetRecipeIngredientUnitResponseV1?, @SerialName("unit") val unit: GetUnitResponse?,
@SerialName("food") val food: GetRecipeIngredientFoodResponseV1?, @SerialName("food") val food: GetFoodResponse?,
@SerialName("quantity") val quantity: Double?, @SerialName("quantity") val quantity: Double?,
@SerialName("title") val title: String?, @SerialName("title") val title: String?,
) )
@Serializable @Serializable
data class GetRecipeInstructionResponseV1( data class GetRecipeInstructionResponse(
@SerialName("text") val text: String, @SerialName("text") val text: String,
) )

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.datasource.v1.models package gq.kirmanak.mealient.datasource.models
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
@@ -6,7 +6,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class GetRecipeSummaryResponseV1( data class GetRecipeSummaryResponse(
@SerialName("id") val remoteId: String, @SerialName("id") val remoteId: String,
@SerialName("name") val name: String, @SerialName("name") val name: String,
@SerialName("slug") val slug: String, @SerialName("slug") val slug: String,

View File

@@ -1,9 +1,9 @@
package gq.kirmanak.mealient.datasource.v1.models package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class GetRecipesResponseV1( data class GetRecipesResponse(
@SerialName("items") val items: List<GetRecipeSummaryResponseV1>, @SerialName("items") val items: List<GetRecipeSummaryResponse>,
) )

View File

@@ -1,19 +1,19 @@
package gq.kirmanak.mealient.datasource.v1.models package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class GetShoppingListResponseV1( data class GetShoppingListResponse(
@SerialName("id") val id: String, @SerialName("id") val id: String,
@SerialName("groupId") val groupId: String, @SerialName("groupId") val groupId: String,
@SerialName("name") val name: String = "", @SerialName("name") val name: String = "",
@SerialName("listItems") val listItems: List<GetShoppingListItemResponseV1> = emptyList(), @SerialName("listItems") val listItems: List<GetShoppingListItemResponse> = emptyList(),
@SerialName("recipeReferences") val recipeReferences: List<GetShoppingListItemRecipeReferenceFullResponseV1>, @SerialName("recipeReferences") val recipeReferences: List<GetShoppingListItemRecipeReferenceFullResponse>,
) )
@Serializable @Serializable
data class GetShoppingListItemResponseV1( data class GetShoppingListItemResponse(
@SerialName("shoppingListId") val shoppingListId: String, @SerialName("shoppingListId") val shoppingListId: String,
@SerialName("id") val id: String, @SerialName("id") val id: String,
@SerialName("checked") val checked: Boolean = false, @SerialName("checked") val checked: Boolean = false,
@@ -21,22 +21,22 @@ data class GetShoppingListItemResponseV1(
@SerialName("isFood") val isFood: Boolean = false, @SerialName("isFood") val isFood: Boolean = false,
@SerialName("note") val note: String = "", @SerialName("note") val note: String = "",
@SerialName("quantity") val quantity: Double = 0.0, @SerialName("quantity") val quantity: Double = 0.0,
@SerialName("unit") val unit: GetRecipeIngredientUnitResponseV1? = null, @SerialName("unit") val unit: GetUnitResponse? = null,
@SerialName("food") val food: GetRecipeIngredientFoodResponseV1? = null, @SerialName("food") val food: GetFoodResponse? = null,
@SerialName("recipeReferences") val recipeReferences: List<GetShoppingListItemRecipeReferenceResponseV1> = emptyList(), @SerialName("recipeReferences") val recipeReferences: List<GetShoppingListItemRecipeReferenceResponse> = emptyList(),
) )
@Serializable @Serializable
data class GetShoppingListItemRecipeReferenceResponseV1( data class GetShoppingListItemRecipeReferenceResponse(
@SerialName("recipeId") val recipeId: String, @SerialName("recipeId") val recipeId: String,
@SerialName("recipeQuantity") val recipeQuantity: Double = 0.0 @SerialName("recipeQuantity") val recipeQuantity: Double = 0.0
) )
@Serializable @Serializable
data class GetShoppingListItemRecipeReferenceFullResponseV1( data class GetShoppingListItemRecipeReferenceFullResponse(
@SerialName("id") val id: String, @SerialName("id") val id: String,
@SerialName("shoppingListId") val shoppingListId: String, @SerialName("shoppingListId") val shoppingListId: String,
@SerialName("recipeId") val recipeId: String, @SerialName("recipeId") val recipeId: String,
@SerialName("recipeQuantity") val recipeQuantity: Double = 0.0, @SerialName("recipeQuantity") val recipeQuantity: Double = 0.0,
@SerialName("recipe") val recipe: GetRecipeResponseV1, @SerialName("recipe") val recipe: GetRecipeResponse,
) )

View File

@@ -1,13 +1,13 @@
package gq.kirmanak.mealient.datasource.v1.models package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class GetShoppingListsResponseV1( data class GetShoppingListsResponse(
@SerialName("page") val page: Int, @SerialName("page") val page: Int,
@SerialName("per_page") val perPage: Int, @SerialName("per_page") val perPage: Int,
@SerialName("total") val total: Int, @SerialName("total") val total: Int,
@SerialName("total_pages") val totalPages: Int, @SerialName("total_pages") val totalPages: Int,
@SerialName("items") val items: List<GetShoppingListsSummaryResponseV1>, @SerialName("items") val items: List<GetShoppingListsSummaryResponse>,
) )

View File

@@ -1,10 +1,10 @@
package gq.kirmanak.mealient.datasource.v1.models package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class GetShoppingListsSummaryResponseV1( data class GetShoppingListsSummaryResponse(
@SerialName("id") val id: String, @SerialName("id") val id: String,
@SerialName("name") val name: String?, @SerialName("name") val name: String?,
) )

View File

@@ -1,9 +1,9 @@
package gq.kirmanak.mealient.datasource.v1.models package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class GetTokenResponseV1( data class GetTokenResponse(
@SerialName("access_token") val accessToken: String, @SerialName("access_token") val accessToken: String,
) )

View File

@@ -1,15 +1,15 @@
package gq.kirmanak.mealient.datasource.v1.models package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class GetUnitsResponseV1( data class GetUnitsResponse(
@SerialName("items") val items: List<GetUnitResponseV1> @SerialName("items") val items: List<GetUnitResponse>
) )
@Serializable @Serializable
data class GetUnitResponseV1( data class GetUnitResponse(
@SerialName("name") val name: String, @SerialName("name") val name: String,
@SerialName("id") val id: String @SerialName("id") val id: String
) )

View File

@@ -1,10 +1,10 @@
package gq.kirmanak.mealient.datasource.v1.models package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class GetUserInfoResponseV1( data class GetUserInfoResponse(
@SerialName("id") val id: String, @SerialName("id") val id: String,
@SerialName("favoriteRecipes") val favoriteRecipes: List<String> = emptyList(), @SerialName("favoriteRecipes") val favoriteRecipes: List<String> = emptyList(),
) )

View File

@@ -1,11 +0,0 @@
package gq.kirmanak.mealient.datasource.models
data class NewShoppingListItemInfo(
val shoppingListId: String,
val isFood: Boolean,
val note: String,
val quantity: Double,
val unit: UnitInfo?,
val food: FoodInfo?,
val position: Int,
)

View File

@@ -1,6 +0,0 @@
package gq.kirmanak.mealient.datasource.models
data class ParseRecipeURLInfo(
val url: String,
val includeTags: Boolean
)

View File

@@ -1,10 +1,10 @@
package gq.kirmanak.mealient.datasource.v1.models package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class ParseRecipeURLRequestV1( data class ParseRecipeURLRequest(
@SerialName("url") val url: String, @SerialName("url") val url: String,
@SerialName("includeTags") val includeTags: Boolean @SerialName("includeTags") val includeTags: Boolean
) )

Some files were not shown because too many files have changed in this diff Show More