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

View File

@@ -5,11 +5,15 @@ import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
val versionV1Response = MockResponse().setResponseCode(200).setBody(
"""{"production":true,"version":"v1.0.0beta-5","demoStatus":false,"allowSignup":true}"""
)
val versionV1Response = MockResponse()
.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) {
dispatcher = object : Dispatcher() {

View File

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

View File

@@ -4,9 +4,9 @@ import kotlinx.coroutines.flow.Flow
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
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.ServerVersion
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 gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.datasource.models.CreateApiTokenRequest
import javax.inject.Inject
class AuthDataSourceImpl @Inject constructor(
private val serverInfoRepo: ServerInfoRepo,
private val v0Source: MealieDataSourceV0,
private val v1Source: MealieDataSourceV1,
private val dataSource: MealieDataSource,
) : AuthDataSource {
private suspend fun getVersion(): ServerVersion = serverInfoRepo.getVersion()
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 authenticate(username: String, password: String): String {
return dataSource.authenticate(username, password)
}
override suspend fun createApiToken(name: String): String = when (getVersion()) {
ServerVersion.V0 -> v0Source.createApiToken(CreateApiTokenRequestV0(name)).token
ServerVersion.V1 -> v1Source.createApiToken(CreateApiTokenRequestV1(name)).token
override suspend fun createApiToken(name: String): String {
return dataSource.createApiToken(CreateApiTokenRequest(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.AuthStorage
import gq.kirmanak.mealient.datasource.AuthenticationProvider
import gq.kirmanak.mealient.datasource.SignOutHandler
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -13,28 +14,29 @@ class AuthRepoImpl @Inject constructor(
private val authStorage: AuthStorage,
private val authDataSource: AuthDataSource,
private val logger: Logger,
private val signOutHandler: SignOutHandler,
) : AuthRepo, AuthenticationProvider {
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) {
logger.v { "authenticate() called with: email = $email, password = $password" }
val token = authDataSource.authenticate(email, password)
authStorage.setAuthHeader(AUTH_HEADER_FORMAT.format(token))
authStorage.setAuthToken(token)
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() {
logger.v { "logout() called" }
authStorage.setAuthHeader(null)
authStorage.setAuthToken(null)
signOutHandler.signOut()
}
companion object {
private const val AUTH_HEADER_FORMAT = "Bearer %s"
private const val API_TOKEN_NAME = "Mealient"
}
}

View File

@@ -22,15 +22,15 @@ class AuthStorageImpl @Inject constructor(
private val logger: Logger,
) : AuthStorage {
override val authHeaderFlow: Flow<String?>
override val authTokenFlow: Flow<String?>
get() = sharedPreferences
.prefsChangeFlow(logger) { getString(AUTH_HEADER_KEY, null) }
.prefsChangeFlow(logger) { getString(AUTH_TOKEN_KEY, null) }
.distinctUntilChanged()
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(
key: String,
@@ -48,6 +48,6 @@ class AuthStorageImpl @Inject constructor(
companion object {
@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
import kotlinx.coroutines.flow.Flow
interface ServerInfoRepo {
suspend fun getUrl(): String?
suspend fun getVersion(): ServerVersion
suspend fun tryBaseURL(baseURL: String): Result<Unit>
fun versionUpdates(): Flow<ServerVersion>
}

View File

@@ -1,11 +1,7 @@
package gq.kirmanak.mealient.data.baseurl
import gq.kirmanak.mealient.datasource.ServerUrlProvider
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
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
class ServerInfoRepoImpl @Inject constructor(
@@ -20,47 +16,18 @@ class ServerInfoRepoImpl @Inject constructor(
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> {
val oldVersion = serverInfoStorage.getServerVersion()
val oldBaseUrl = serverInfoStorage.getBaseURL()
serverInfoStorage.storeBaseURL(baseURL)
return runCatchingExceptCancel {
serverInfoStorage.storeBaseURL(baseURL)
val version = versionDataSource.getVersionInfo().version
serverInfoStorage.storeServerVersion(version)
}.onFailure {
serverInfoStorage.storeBaseURL(oldBaseUrl, oldVersion)
try {
versionDataSource.requestVersion()
} catch (e: Throwable) {
serverInfoStorage.storeBaseURL(oldBaseUrl)
return Result.failure(e)
}
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
import kotlinx.coroutines.flow.Flow
interface ServerInfoStorage {
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
import gq.kirmanak.mealient.datasource.models.VersionInfo
import gq.kirmanak.mealient.datasource.models.VersionResponse
interface VersionDataSource {
suspend fun getVersionInfo(): VersionInfo
suspend fun requestVersion(): VersionResponse
}

View File

@@ -1,37 +1,14 @@
package gq.kirmanak.mealient.data.baseurl
import gq.kirmanak.mealient.datasource.models.VersionInfo
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
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 gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.datasource.models.VersionResponse
import javax.inject.Inject
class VersionDataSourceImpl @Inject constructor(
private val v0Source: MealieDataSourceV0,
private val v1Source: MealieDataSourceV1,
private val modelMapper: ModelMapper,
private val dataSource: MealieDataSource,
) : VersionDataSource {
override suspend fun getVersionInfo(): VersionInfo {
val responses = coroutineScope {
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
}
override suspend fun requestVersion(): VersionResponse {
return dataSource.getVersionInfo()
}
}

View File

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

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
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.share.ParseRecipeDataSource
import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.datasource.models.AddRecipeInfo
import gq.kirmanak.mealient.datasource.models.FullRecipeInfo
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLInfo
import gq.kirmanak.mealient.datasource.models.RecipeSummaryInfo
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLRequest
import gq.kirmanak.mealient.model_mapper.ModelMapper
import javax.inject.Inject
class MealieDataSourceWrapper @Inject constructor(
private val serverInfoRepo: ServerInfoRepo,
private val v0Source: MealieDataSourceV0,
private val v1Source: MealieDataSourceV1,
private val dataSource: MealieDataSource,
private val modelMapper: ModelMapper,
) : AddRecipeDataSource, RecipeDataSource, ParseRecipeDataSource {
private suspend fun getVersion(): ServerVersion = serverInfoRepo.getVersion()
override suspend fun addRecipe(recipe: AddRecipeInfo): String = when (getVersion()) {
ServerVersion.V0 -> v0Source.addRecipe(modelMapper.toV0Request(recipe))
ServerVersion.V1 -> {
val slug = v1Source.createRecipe(modelMapper.toV1CreateRequest(recipe))
v1Source.updateRecipe(slug, modelMapper.toV1UpdateRequest(recipe))
slug
}
override suspend fun addRecipe(recipe: AddRecipeInfo): String {
val slug = dataSource.createRecipe(modelMapper.toCreateRequest(recipe))
dataSource.updateRecipe(slug, modelMapper.toUpdateRequest(recipe))
return slug
}
override suspend fun requestRecipes(
start: Int,
limit: Int,
): List<RecipeSummaryInfo> = when (getVersion()) {
ServerVersion.V0 -> {
v0Source.requestRecipes(start, limit).map { modelMapper.toRecipeSummaryInfo(it) }
}
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
v1Source.requestRecipes(page, limit).map { modelMapper.toRecipeSummaryInfo(it) }
): List<GetRecipeSummaryResponse> {
// 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
return dataSource.requestRecipes(page, limit)
}
override suspend fun requestRecipe(slug: String): GetRecipeResponse {
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()) {
ServerVersion.V0 -> modelMapper.toFullRecipeInfo(v0Source.requestRecipeInfo(slug))
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)
override suspend fun deleteRecipe(recipeSlug: String) {
dataSource.deleteRecipe(recipeSlug)
}
}

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
package gq.kirmanak.mealient.data.recipes.network
import gq.kirmanak.mealient.datasource.models.FullRecipeInfo
import gq.kirmanak.mealient.datasource.models.RecipeSummaryInfo
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -109,7 +109,6 @@ class MainActivity : BaseActivity<MainActivityBinding>(
when (itemId) {
R.id.logout -> menuItem.isVisible = uiState.canShowLogout
R.id.login -> menuItem.isVisible = uiState.canShowLogin
R.id.shopping_lists -> menuItem.isVisible = uiState.v1MenuItemsVisible
}
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.data.auth.AuthRepo
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.recipes.RecipeRepo
import gq.kirmanak.mealient.logging.Logger
@@ -47,10 +46,6 @@ class MainActivityViewModel @Inject constructor(
.onEach { isAuthorized -> updateUiState { it.copy(isAuthorized = isAuthorized) } }
.launchIn(viewModelScope)
serverInfoRepo.versionUpdates()
.onEach { version -> updateUiState { it.copy(v1MenuItemsVisible = version == ServerVersion.V1) } }
.launchIn(viewModelScope)
viewModelScope.launch {
_startDestination.value = when {
!disclaimerStorage.isDisclaimerAccepted() -> {

View File

@@ -14,7 +14,6 @@
<item
android:id="@+id/shopping_lists"
android:visible="false"
android:checkable="true"
android:icon="@drawable/ic_shopping_cart"
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.ModelMapperImpl
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 kotlinx.coroutines.ExperimentalCoroutinesApi
import io.mockk.verify
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AddRecipeRepoTest : BaseUnitTest() {
@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.AuthRepo
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.test.AuthImplTestData.TEST_API_AUTH_HEADER
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_SERVER_VERSION_V0
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AuthRepoImplTest : BaseUnitTest() {
@MockK
lateinit var dataSource: AuthDataSource
@MockK
lateinit var serverInfoRepo: ServerInfoRepo
@MockK(relaxUnitFun = true)
lateinit var storage: AuthStorage
@MockK(relaxUnitFun = true)
lateinit var signOutHandler: SignOutHandler
lateinit var subject: AuthRepo
@Before
override fun setUp() {
super.setUp()
subject = AuthRepoImpl(storage, dataSource, logger)
subject = AuthRepoImpl(storage, dataSource, logger, signOutHandler)
}
@Test
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))
}
@Test
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.createApiToken(any()) } returns TEST_API_TOKEN
subject.authenticate(TEST_USERNAME, TEST_PASSWORD)
coVerify {
dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD))
storage.setAuthHeader(TEST_AUTH_HEADER)
storage.setAuthToken(TEST_TOKEN)
dataSource.createApiToken(eq("Mealient"))
storage.setAuthHeader(TEST_API_AUTH_HEADER)
storage.setAuthToken(TEST_API_TOKEN)
}
confirmVerified(storage)
}
@@ -74,7 +71,7 @@ class AuthRepoImplTest : BaseUnitTest() {
@Test
fun `when logout expect header removal`() = runTest {
subject.logout()
coVerify { storage.setAuthHeader(null) }
coVerify { storage.setAuthToken(null) }
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.testing.HiltAndroidTest
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl.Companion.AUTH_HEADER_KEY
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl.Companion.AUTH_TOKEN_KEY
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.HiltRobolectricTest
import io.mockk.MockKAnnotations
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class AuthStorageImplTest : HiltRobolectricTest() {
@@ -39,12 +37,12 @@ class AuthStorageImplTest : HiltRobolectricTest() {
@Test
fun `when authHeaderFlow is observed then sends value immediately`() = runTest {
sharedPreferences.edit(commit = true) { putString(AUTH_HEADER_KEY, TEST_AUTH_HEADER) }
assertThat(subject.authHeaderFlow.first()).isEqualTo(TEST_AUTH_HEADER)
sharedPreferences.edit(commit = true) { putString(AUTH_TOKEN_KEY, TEST_TOKEN) }
assertThat(subject.authTokenFlow.first()).isEqualTo(TEST_TOKEN)
}
@Test
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
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_VERSION
import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class ServerInfoRepoTest : BaseUnitTest() {
private lateinit var subject: ServerInfoRepo
@@ -54,104 +48,10 @@ class ServerInfoRepoTest : BaseUnitTest() {
@Test
fun `when tryBaseURL succeeds expect call to storage`() = runTest {
coEvery { storage.getServerVersion() } returns null
coEvery { storage.getBaseURL() } returns null
coEvery { dataSource.getVersionInfo() } returns VersionInfo(TEST_VERSION)
subject.tryBaseURL(TEST_BASE_URL)
coVerify {
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.storage.PreferencesStorage
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_VERSION
import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ServerInfoStorageTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
@@ -25,14 +22,12 @@ class ServerInfoStorageTest : BaseUnitTest() {
lateinit var subject: ServerInfoStorage
private val baseUrlKey = stringPreferencesKey("baseUrlKey")
private val serverVersionKey = stringPreferencesKey("serverVersionKey")
@Before
override fun setUp() {
super.setUp()
subject = ServerInfoStorageImpl(preferencesStorage)
every { preferencesStorage.baseUrlKey } returns baseUrlKey
every { preferencesStorage.serverVersionKey } returns serverVersionKey
}
@Test
@@ -49,30 +44,11 @@ class ServerInfoStorageTest : BaseUnitTest() {
@Test
fun `when storeBaseURL expect call to preferences storage`() = runTest {
subject.storeBaseURL(TEST_BASE_URL, TEST_VERSION)
subject.storeBaseURL(TEST_BASE_URL)
coVerify {
preferencesStorage.storeValues(
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 dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class DisclaimerStorageImplTest : HiltRobolectricTest() {

View File

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

View File

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

View File

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

View File

@@ -1,27 +1,26 @@
package gq.kirmanak.mealient.data.recipes.impl
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.data.baseurl.ServerInfoStorage
import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
class RecipeImageUrlProviderImplTest : BaseUnitTest() {
@HiltAndroidTest
class RecipeImageUrlProviderImplTest : HiltRobolectricTest() {
@Inject
lateinit var subject: RecipeImageUrlProvider
@MockK
lateinit var serverInfoRepo: ServerInfoRepo
@Inject
lateinit var serverInfoStorage: ServerInfoStorage
@Before
override fun setUp() {
super.setUp()
subject = RecipeImageUrlProviderImpl(serverInfoRepo, logger)
fun setUp() {
prepareBaseURL("https://google.com/")
}
@@ -76,7 +75,7 @@ class RecipeImageUrlProviderImplTest : BaseUnitTest() {
assertThat(actual).isNull()
}
private fun prepareBaseURL(baseURL: String?) {
coEvery { serverInfoRepo.getUrl() } returns baseURL
private fun prepareBaseURL(baseURL: String?) = runBlocking {
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.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
@OptIn(ExperimentalCoroutinesApi::class)
class RecipePagingSourceFactoryImplTest : HiltRobolectricTest() {
@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.recipe.RecipeStorage
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.ModelMapperImpl
import gq.kirmanak.mealient.test.BaseUnitTest
@@ -22,13 +22,11 @@ import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.impl.annotations.MockK
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class RecipeRepoTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
@@ -69,7 +67,7 @@ class RecipeRepoTest : BaseUnitTest() {
@Test
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")
coVerify {
storage.saveRecipeInfo(

View File

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

View File

@@ -1,15 +1,13 @@
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 io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ShareRecipeRepoImplTest : BaseUnitTest() {
@@ -32,7 +30,7 @@ class ShareRecipeRepoImplTest : BaseUnitTest() {
@Test
fun `when url is correct expect saveRecipeByURL saves it`() = runTest {
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/",
includeTags = true
)
@@ -42,7 +40,7 @@ class ShareRecipeRepoImplTest : BaseUnitTest() {
@Test
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/")
val expected = ParseRecipeURLInfo(
val expected = ParseRecipeURLRequest(
url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/",
includeTags = true
)
@@ -52,7 +50,7 @@ class ShareRecipeRepoImplTest : BaseUnitTest() {
@Test
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")
val expected = ParseRecipeURLInfo(
val expected = ParseRecipeURLRequest(
url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie",
includeTags = true
)
@@ -62,7 +60,7 @@ class ShareRecipeRepoImplTest : BaseUnitTest() {
@Test
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")
val expected = ParseRecipeURLInfo(
val expected = ParseRecipeURLRequest(
url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie",
includeTags = true
)

View File

@@ -3,13 +3,11 @@ package gq.kirmanak.mealient.data.storage
import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class PreferencesStorageImplTest : HiltRobolectricTest() {

View File

@@ -1,22 +1,14 @@
package gq.kirmanak.mealient.test
import gq.kirmanak.mealient.data.baseurl.ServerVersion
import gq.kirmanak.mealient.datasource.v0.models.GetUserInfoResponseV0
import gq.kirmanak.mealient.datasource.v1.models.GetUserInfoResponseV1
import gq.kirmanak.mealient.datasource.models.GetUserInfoResponse
object AuthImplTestData {
const val TEST_USERNAME = "TEST_USERNAME"
const val TEST_PASSWORD = "TEST_PASSWORD"
const val TEST_BASE_URL = "https://example.com"
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_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 USER_INFO_V1 = GetUserInfoResponseV1("userId", FAVORITE_RECIPES_LIST)
val USER_INFO_V0 = GetUserInfoResponseV0(3, FAVORITE_RECIPES_LIST)
val USER_INFO = GetUserInfoResponse("userId", FAVORITE_RECIPES_LIST)
}

View File

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

View File

@@ -7,7 +7,6 @@ import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOf
@@ -16,7 +15,6 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AddRecipeViewModelTest : BaseUnitTest() {
@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 io.mockk.coEvery
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class RecipeInfoViewModelTest : BaseUnitTest() {
@MockK

View File

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

View File

@@ -2,13 +2,11 @@ package gq.kirmanak.mealient.architecture
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.test.BaseUnitTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class FlowExtensionsKtTest : BaseUnitTest() {
@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.RecipeStorageImpl
import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
@OptIn(ExperimentalCoroutinesApi::class)
internal class RecipeStorageImplTest : HiltRobolectricTest() {
@Inject

View File

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

View File

@@ -25,16 +25,19 @@ dependencies {
implementation(libs.jetbrains.kotlinx.serialization)
implementation(libs.squareup.retrofit)
implementation(libs.jakewharton.retrofitSerialization)
implementation(libs.jetbrains.kotlinx.coroutinesAndroid)
testImplementation(libs.jetbrains.kotlinx.coroutinesTest)
implementation(platform(libs.okhttp3.bom))
implementation(libs.okhttp3.okhttp)
debugImplementation(libs.okhttp3.loggingInterceptor)
implementation(libs.jetbrains.kotlinx.coroutinesAndroid)
testImplementation(libs.jetbrains.kotlinx.coroutinesTest)
implementation(libs.ktor.core)
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)

View File

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

View File

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

View File

@@ -2,8 +2,7 @@ package gq.kirmanak.mealient.datasource
interface AuthenticationProvider {
suspend fun getAuthHeader(): String?
suspend fun getAuthToken(): String?
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
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
@@ -18,9 +14,6 @@ inline fun <T> runCatchingExceptCancel(block: () -> T): Result<T> = try {
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? {
var cause: Throwable? = this
var previousCause: Throwable? = null

View File

@@ -1,32 +1,17 @@
package gq.kirmanak.mealient.datasource
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
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.impl.AuthInterceptor
import gq.kirmanak.mealient.datasource.impl.BaseUrlInterceptor
import gq.kirmanak.mealient.datasource.impl.CacheBuilderImpl
import gq.kirmanak.mealient.datasource.impl.MealieDataSourceImpl
import gq.kirmanak.mealient.datasource.impl.MealieServiceKtor
import gq.kirmanak.mealient.datasource.impl.NetworkRequestWrapperImpl
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.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 okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Converter
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Singleton
@Module
@@ -43,59 +28,22 @@ internal interface DataSourceModule {
encodeDefaults = true
}
@OptIn(ExperimentalSerializationApi::class)
@Provides
@Singleton
fun provideConverterFactory(json: Json): Converter.Factory =
json.asConverterFactory("application/json".toMediaType())
@Provides
@Singleton
fun provideOkHttp(okHttpBuilder: OkHttpBuilder): OkHttpClient =
fun provideOkHttp(okHttpBuilder: OkHttpBuilderImpl): OkHttpClient =
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
fun bindCacheBuilder(cacheBuilderImpl: CacheBuilderImpl): CacheBuilder
fun bindMealieDataSource(mealientDataSourceImpl: MealieDataSourceImpl): MealieDataSource
@Binds
fun bindOkHttpBuilder(okHttpBuilderImpl: OkHttpBuilderImpl): OkHttpBuilder
@Binds
fun bindMealieDataSource(mealientDataSourceImpl: MealieDataSourceV0Impl): MealieDataSourceV0
@Binds
fun bindMealieDataSourceV1(mealientDataSourceImpl: MealieDataSourceV1Impl): MealieDataSourceV1
fun bindMealieService(impl: MealieServiceKtor): MealieService
@Binds
fun bindNetworkRequestWrapper(networkRequestWrapperImpl: NetworkRequestWrapperImpl): NetworkRequestWrapper
@Binds
@IntoSet
fun bindAuthInterceptor(authInterceptor: AuthInterceptor): LocalInterceptor
@Binds
@IntoSet
fun bindBaseUrlInterceptor(baseUrlInterceptor: BaseUrlInterceptor): LocalInterceptor
@Binds
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.os.StatFs
import dagger.hilt.android.qualifiers.ApplicationContext
import gq.kirmanak.mealient.datasource.CacheBuilder
import gq.kirmanak.mealient.logging.Logger
import okhttp3.Cache
import java.io.File
@@ -12,9 +11,9 @@ import javax.inject.Inject
internal class CacheBuilderImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val logger: Logger,
) : CacheBuilder {
) {
override fun buildCache(): Cache {
fun buildCache(): Cache {
val dir = findCacheDir()
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.NetworkRequestWrapper
import gq.kirmanak.mealient.datasource.decode
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenRequestV1
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenResponseV1
import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1
import gq.kirmanak.mealient.datasource.v1.models.CreateShoppingListItemRequestV1
import gq.kirmanak.mealient.datasource.v1.models.ErrorDetailV1
import gq.kirmanak.mealient.datasource.v1.models.GetFoodsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetUnitsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetUserInfoResponseV1
import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1
import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1
import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
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.ErrorDetail
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
import io.ktor.client.call.NoTransformationFoundException
import io.ktor.client.call.body
import io.ktor.client.plugins.ResponseException
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import retrofit2.HttpException
import java.net.ConnectException
import java.net.SocketException
import java.net.SocketTimeoutException
import javax.inject.Inject
class MealieDataSourceV1Impl @Inject constructor(
internal class MealieDataSourceImpl @Inject constructor(
private val networkRequestWrapper: NetworkRequestWrapper,
private val service: MealieServiceV1,
private val json: Json,
) : MealieDataSourceV1 {
private val service: MealieService,
) : MealieDataSource {
override suspend fun createRecipe(
recipe: CreateRecipeRequestV1
recipe: CreateRecipeRequest
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipe(recipe) },
logMethod = { "createRecipe" },
logParameters = { "recipe = $recipe" }
)
).trim('"')
override suspend fun updateRecipe(
slug: String,
recipe: UpdateRecipeRequestV1
): GetRecipeResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized(
recipe: UpdateRecipeRequest
): GetRecipeResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateRecipe(recipe, slug) },
logMethod = { "updateRecipe" },
logParameters = { "slug = $slug, recipe = $recipe" }
@@ -61,18 +61,17 @@ class MealieDataSourceV1Impl @Inject constructor(
logMethod = { "authenticate" },
logParameters = { "username = $username, password = $password" }
).map { it.accessToken }.getOrElse {
val errorBody = (it as? HttpException)?.response()?.errorBody() ?: throw it
val errorDetailV0 = errorBody.decode<ErrorDetailV1>(json)
throw if (errorDetailV0.detail == "Unauthorized") NetworkError.Unauthorized(it) else it
val errorDetail = (it as? ResponseException)?.response?.body<ErrorDetail>() ?: throw it
throw if (errorDetail.detail == "Unauthorized") NetworkError.Unauthorized(it) else it
}
override suspend fun getVersionInfo(): VersionResponseV1 = networkRequestWrapper.makeCall(
override suspend fun getVersionInfo(): VersionResponse = networkRequestWrapper.makeCall(
block = { service.getVersion() },
logMethod = { "getVersionInfo" },
).getOrElse {
throw when (it) {
is HttpException, is SerializationException -> NetworkError.NotMealie(it)
is SocketTimeoutException, is ConnectException -> NetworkError.NoServerConnection(it)
is ResponseException, is NoTransformationFoundException -> NetworkError.NotMealie(it)
is SocketTimeoutException, is SocketException -> NetworkError.NoServerConnection(it)
else -> NetworkError.MalformedUrl(it)
}
}
@@ -80,7 +79,7 @@ class MealieDataSourceV1Impl @Inject constructor(
override suspend fun requestRecipes(
page: Int,
perPage: Int
): List<GetRecipeSummaryResponseV1> = networkRequestWrapper.makeCallAndHandleUnauthorized(
): List<GetRecipeSummaryResponse> = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipeSummary(page, perPage) },
logMethod = { "requestRecipes" },
logParameters = { "page = $page, perPage = $perPage" }
@@ -88,14 +87,14 @@ class MealieDataSourceV1Impl @Inject constructor(
override suspend fun requestRecipeInfo(
slug: String
): GetRecipeResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized(
): GetRecipeResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipe(slug) },
logMethod = { "requestRecipeInfo" },
logParameters = { "slug = $slug" }
)
override suspend fun parseRecipeFromURL(
request: ParseRecipeURLRequestV1
request: ParseRecipeURLRequest
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipeFromURL(request) },
logMethod = { "parseRecipeFromURL" },
@@ -103,14 +102,14 @@ class MealieDataSourceV1Impl @Inject constructor(
)
override suspend fun createApiToken(
request: CreateApiTokenRequestV1
): CreateApiTokenResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized(
request: CreateApiTokenRequest
): CreateApiTokenResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createApiToken(request) },
logMethod = { "createApiToken" },
logParameters = { "request = $request" }
)
override suspend fun requestUserInfo(): GetUserInfoResponseV1 {
override suspend fun requestUserInfo(): GetUserInfoResponse {
return networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getUserSelfInfo() },
logMethod = { "requestUserInfo" },
@@ -146,7 +145,7 @@ class MealieDataSourceV1Impl @Inject constructor(
override suspend fun getShoppingLists(
page: Int,
perPage: Int,
): GetShoppingListsResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized(
): GetShoppingListsResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingLists(page, perPage) },
logMethod = { "getShoppingLists" },
logParameters = { "page = $page, perPage = $perPage" }
@@ -154,7 +153,7 @@ class MealieDataSourceV1Impl @Inject constructor(
override suspend fun getShoppingList(
id: String
): GetShoppingListResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized(
): GetShoppingListResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingList(id) },
logMethod = { "getShoppingList" },
logParameters = { "id = $id" }
@@ -186,7 +185,7 @@ class MealieDataSourceV1Impl @Inject constructor(
)
override suspend fun updateShoppingListItem(
item: ShoppingListItemInfo
item: GetShoppingListItemResponse
) {
// Has to be done in two steps because we can't specify only the changed fields
val remoteItem = getShoppingListItem(item.id)
@@ -203,14 +202,14 @@ class MealieDataSourceV1Impl @Inject constructor(
updateShoppingListItem(item.id, JsonObject(updatedItem))
}
override suspend fun getFoods(): GetFoodsResponseV1 {
override suspend fun getFoods(): GetFoodsResponse {
return networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getFoods(perPage = -1) },
logMethod = { "getFoods" },
)
}
override suspend fun getUnits(): GetUnitsResponseV1 {
override suspend fun getUnits(): GetUnitsResponse {
return networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getUnits(perPage = -1) },
logMethod = { "getUnits" },
@@ -218,7 +217,7 @@ class MealieDataSourceV1Impl @Inject constructor(
}
override suspend fun addShoppingListItem(
request: CreateShoppingListItemRequestV1
request: CreateShoppingListItemRequest
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createShoppingListItem(request) },
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.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import retrofit2.HttpException
import io.ktor.client.plugins.ResponseException
import javax.inject.Inject
internal class NetworkRequestWrapperImpl @Inject constructor(
@@ -49,7 +49,8 @@ internal class NetworkRequestWrapperImpl @Inject constructor(
logMethod: () -> String,
logParameters: (() -> String)?
): 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)
} else {
it

View File

@@ -1,55 +1,28 @@
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 okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.TlsVersion
import javax.inject.Inject
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
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)
private val interceptors: Set<@JvmSuppressWildcards Interceptor>,
private val localInterceptors: Set<@JvmSuppressWildcards LocalInterceptor>,
private val advancedX509TrustManager: AdvancedX509TrustManager,
private val sslSocketFactoryFactory: SslSocketFactoryFactory,
private val logger: Logger,
) : OkHttpBuilder {
) {
override fun buildOkHttp(): OkHttpClient {
logger.v { "buildOkHttp() was called with cacheBuilder = $cacheBuilder, interceptors = $interceptors, localInterceptors = $localInterceptors" }
fun buildOkHttp(): OkHttpClient {
logger.v { "buildOkHttp() was called with cacheBuilder = $cacheBuilder, interceptors = $interceptors" }
val sslContext = buildSSLContext()
sslContext.init(null, arrayOf<TrustManager>(advancedX509TrustManager), null)
val sslSocketFactory = sslContext.socketFactory
val sslSocketFactory = sslSocketFactoryFactory.create()
return OkHttpClient.Builder().apply {
localInterceptors.forEach(::addInterceptor)
interceptors.forEach(::addNetworkInterceptor)
sslSocketFactory(sslSocketFactory, advancedX509TrustManager)
cache(cacheBuilder.buildCache())
}.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.Serializable
@Serializable
data class CreateRecipeRequestV1(
data class CreateApiTokenRequest(
@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.Serializable
@Serializable
data class CreateApiTokenResponseV1(
data class CreateApiTokenResponse(
@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.Serializable
@Serializable
data class CreateApiTokenRequestV1(
data class CreateRecipeRequest(
@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.Serializable
@Serializable
data class CreateShoppingListItemRequestV1(
data class CreateShoppingListItemRequest(
@SerialName("shopping_list_id") val shoppingListId: String,
@SerialName("checked") val checked: Boolean,
@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.Serializable
@Serializable
data class ErrorDetailV1(
data class ErrorDetail(
@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.Serializable
@Serializable
data class GetFoodsResponseV1(
@SerialName("items") val items: List<GetFoodResponseV1>,
data class GetFoodsResponse(
@SerialName("items") val items: List<GetFoodResponse>,
)
@Serializable
data class GetFoodResponseV1(
data class GetFoodResponse(
@SerialName("name") val name: 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.Serializable
@Serializable
data class GetRecipeResponseV1(
data class GetRecipeResponse(
@SerialName("id") val remoteId: String,
@SerialName("name") val name: String,
@SerialName("recipeYield") val recipeYield: String = "",
@SerialName("recipeIngredient") val recipeIngredients: List<GetRecipeIngredientResponseV1> = emptyList(),
@SerialName("recipeInstructions") val recipeInstructions: List<GetRecipeInstructionResponseV1> = emptyList(),
@SerialName("settings") val settings: GetRecipeSettingsResponseV1? = null,
@SerialName("recipeIngredient") val recipeIngredients: List<GetRecipeIngredientResponse> = emptyList(),
@SerialName("recipeInstructions") val recipeInstructions: List<GetRecipeInstructionResponse> = emptyList(),
@SerialName("settings") val settings: GetRecipeSettingsResponse? = null,
)
@Serializable
data class GetRecipeSettingsResponseV1(
data class GetRecipeSettingsResponse(
@SerialName("disableAmount") val disableAmount: Boolean,
)
@Serializable
data class GetRecipeIngredientResponseV1(
data class GetRecipeIngredientResponse(
@SerialName("note") val note: String = "",
@SerialName("unit") val unit: GetRecipeIngredientUnitResponseV1?,
@SerialName("food") val food: GetRecipeIngredientFoodResponseV1?,
@SerialName("unit") val unit: GetUnitResponse?,
@SerialName("food") val food: GetFoodResponse?,
@SerialName("quantity") val quantity: Double?,
@SerialName("title") val title: String?,
)
@Serializable
data class GetRecipeInstructionResponseV1(
data class GetRecipeInstructionResponse(
@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.LocalDateTime
@@ -6,7 +6,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeSummaryResponseV1(
data class GetRecipeSummaryResponse(
@SerialName("id") val remoteId: String,
@SerialName("name") val name: 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.Serializable
@Serializable
data class GetRecipesResponseV1(
@SerialName("items") val items: List<GetRecipeSummaryResponseV1>,
data class GetRecipesResponse(
@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.Serializable
@Serializable
data class GetShoppingListResponseV1(
data class GetShoppingListResponse(
@SerialName("id") val id: String,
@SerialName("groupId") val groupId: String,
@SerialName("name") val name: String = "",
@SerialName("listItems") val listItems: List<GetShoppingListItemResponseV1> = emptyList(),
@SerialName("recipeReferences") val recipeReferences: List<GetShoppingListItemRecipeReferenceFullResponseV1>,
@SerialName("listItems") val listItems: List<GetShoppingListItemResponse> = emptyList(),
@SerialName("recipeReferences") val recipeReferences: List<GetShoppingListItemRecipeReferenceFullResponse>,
)
@Serializable
data class GetShoppingListItemResponseV1(
data class GetShoppingListItemResponse(
@SerialName("shoppingListId") val shoppingListId: String,
@SerialName("id") val id: String,
@SerialName("checked") val checked: Boolean = false,
@@ -21,22 +21,22 @@ data class GetShoppingListItemResponseV1(
@SerialName("isFood") val isFood: Boolean = false,
@SerialName("note") val note: String = "",
@SerialName("quantity") val quantity: Double = 0.0,
@SerialName("unit") val unit: GetRecipeIngredientUnitResponseV1? = null,
@SerialName("food") val food: GetRecipeIngredientFoodResponseV1? = null,
@SerialName("recipeReferences") val recipeReferences: List<GetShoppingListItemRecipeReferenceResponseV1> = emptyList(),
@SerialName("unit") val unit: GetUnitResponse? = null,
@SerialName("food") val food: GetFoodResponse? = null,
@SerialName("recipeReferences") val recipeReferences: List<GetShoppingListItemRecipeReferenceResponse> = emptyList(),
)
@Serializable
data class GetShoppingListItemRecipeReferenceResponseV1(
data class GetShoppingListItemRecipeReferenceResponse(
@SerialName("recipeId") val recipeId: String,
@SerialName("recipeQuantity") val recipeQuantity: Double = 0.0
)
@Serializable
data class GetShoppingListItemRecipeReferenceFullResponseV1(
data class GetShoppingListItemRecipeReferenceFullResponse(
@SerialName("id") val id: String,
@SerialName("shoppingListId") val shoppingListId: String,
@SerialName("recipeId") val recipeId: String,
@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.Serializable
@Serializable
data class GetShoppingListsResponseV1(
data class GetShoppingListsResponse(
@SerialName("page") val page: Int,
@SerialName("per_page") val perPage: Int,
@SerialName("total") val total: 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.Serializable
@Serializable
data class GetShoppingListsSummaryResponseV1(
data class GetShoppingListsSummaryResponse(
@SerialName("id") val id: 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.Serializable
@Serializable
data class GetTokenResponseV1(
data class GetTokenResponse(
@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.Serializable
@Serializable
data class GetUnitsResponseV1(
@SerialName("items") val items: List<GetUnitResponseV1>
data class GetUnitsResponse(
@SerialName("items") val items: List<GetUnitResponse>
)
@Serializable
data class GetUnitResponseV1(
data class GetUnitResponse(
@SerialName("name") val name: 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.Serializable
@Serializable
data class GetUserInfoResponseV1(
data class GetUserInfoResponse(
@SerialName("id") val id: String,
@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.Serializable
@Serializable
data class ParseRecipeURLRequestV1(
data class ParseRecipeURLRequest(
@SerialName("url") val url: String,
@SerialName("includeTags") val includeTags: Boolean
)

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