Create network module

This commit is contained in:
Kirill Kamakin
2022-08-06 18:20:23 +02:00
parent c2c67730d1
commit e0a4442e72
59 changed files with 560 additions and 479 deletions

View File

@@ -8,7 +8,6 @@ plugins {
id("kotlin-kapt") id("kotlin-kapt")
id("androidx.navigation.safeargs.kotlin") id("androidx.navigation.safeargs.kotlin")
id("dagger.hilt.android.plugin") id("dagger.hilt.android.plugin")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.gms.google-services") id("com.google.gms.google-services")
id("com.google.firebase.crashlytics") id("com.google.firebase.crashlytics")
alias(libs.plugins.appsweep) alias(libs.plugins.appsweep)
@@ -19,8 +18,6 @@ android {
applicationId = "gq.kirmanak.mealient" applicationId = "gq.kirmanak.mealient"
versionCode = 13 versionCode = 13
versionName = "0.2.4" versionName = "0.2.4"
buildConfigField("Boolean", "LOG_NETWORK", "false")
} }
signingConfigs { signingConfigs {
@@ -68,6 +65,7 @@ dependencies {
implementation(project(":database")) implementation(project(":database"))
implementation(project(":datastore")) implementation(project(":datastore"))
implementation(project(":datasource"))
implementation(project(":logging")) implementation(project(":logging"))
implementation(libs.android.material.material) implementation(libs.android.material.material)
@@ -92,16 +90,6 @@ dependencies {
kaptTest(libs.google.dagger.hiltAndroidCompiler) kaptTest(libs.google.dagger.hiltAndroidCompiler)
testImplementation(libs.google.dagger.hiltAndroidTesting) testImplementation(libs.google.dagger.hiltAndroidTesting)
implementation(libs.squareup.retrofit)
implementation(libs.jakewharton.retrofitSerialization)
implementation(platform(libs.okhttp3.bom))
implementation(libs.okhttp3.okhttp)
debugImplementation(libs.okhttp3.loggingInterceptor)
implementation(libs.jetbrains.kotlinx.serialization)
implementation(libs.androidx.paging.runtimeKtx) implementation(libs.androidx.paging.runtimeKtx)
testImplementation(libs.androidx.paging.commonKtx) testImplementation(libs.androidx.paging.commonKtx)
@@ -137,6 +125,4 @@ dependencies {
testImplementation(libs.io.mockk) testImplementation(libs.io.mockk)
debugImplementation(libs.squareup.leakcanary) debugImplementation(libs.squareup.leakcanary)
debugImplementation(libs.chuckerteam.chucker)
} }

View File

@@ -1,8 +1,7 @@
package gq.kirmanak.mealient.data.add package gq.kirmanak.mealient.data.add
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
interface AddRecipeDataSource { interface AddRecipeDataSource {
suspend fun addRecipe(recipe: AddRecipeRequest): String suspend fun addRecipe(recipe: AddRecipeRequest): String
} }

View File

@@ -1,6 +1,6 @@
package gq.kirmanak.mealient.data.add package gq.kirmanak.mealient.data.add
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface AddRecipeRepo { interface AddRecipeRepo {

View File

@@ -1,8 +1,8 @@
package gq.kirmanak.mealient.data.add.impl package gq.kirmanak.mealient.data.add.impl
import gq.kirmanak.mealient.data.add.AddRecipeDataSource import gq.kirmanak.mealient.data.add.AddRecipeDataSource
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper
import gq.kirmanak.mealient.data.network.ServiceFactory import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.extensions.logAndMapErrors import gq.kirmanak.mealient.extensions.logAndMapErrors
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject import javax.inject.Inject
@@ -10,15 +10,14 @@ import javax.inject.Singleton
@Singleton @Singleton
class AddRecipeDataSourceImpl @Inject constructor( class AddRecipeDataSourceImpl @Inject constructor(
private val addRecipeServiceFactory: ServiceFactory<AddRecipeService>,
private val logger: Logger, private val logger: Logger,
private val mealieDataSourceWrapper: MealieDataSourceWrapper,
) : AddRecipeDataSource { ) : AddRecipeDataSource {
override suspend fun addRecipe(recipe: AddRecipeRequest): String { override suspend fun addRecipe(recipe: AddRecipeRequest): String {
logger.v { "addRecipe() called with: recipe = $recipe" } logger.v { "addRecipe() called with: recipe = $recipe" }
val service = addRecipeServiceFactory.provideService()
val response = logger.logAndMapErrors( val response = logger.logAndMapErrors(
block = { service.addRecipe(recipe) }, block = { mealieDataSourceWrapper.addRecipe(recipe) },
logProvider = { "addRecipe: can't add recipe" } logProvider = { "addRecipe: can't add recipe" }
) )
logger.v { "addRecipe() response = $response" } logger.v { "addRecipe() response = $response" }

View File

@@ -2,8 +2,10 @@ package gq.kirmanak.mealient.data.add.impl
import gq.kirmanak.mealient.data.add.AddRecipeDataSource import gq.kirmanak.mealient.data.add.AddRecipeDataSource
import gq.kirmanak.mealient.data.add.AddRecipeRepo import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorage import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorage
import gq.kirmanak.mealient.extensions.toAddRecipeRequest
import gq.kirmanak.mealient.extensions.toDraft
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -19,7 +21,7 @@ class AddRecipeRepoImpl @Inject constructor(
) : AddRecipeRepo { ) : AddRecipeRepo {
override val addRecipeRequestFlow: Flow<AddRecipeRequest> override val addRecipeRequestFlow: Flow<AddRecipeRequest>
get() = addRecipeStorage.updates.map { AddRecipeRequest(it) } get() = addRecipeStorage.updates.map { it.toAddRecipeRequest() }
override suspend fun preserve(recipe: AddRecipeRequest) { override suspend fun preserve(recipe: AddRecipeRequest) {
logger.v { "preserveRecipe() called with: recipe = $recipe" } logger.v { "preserveRecipe() called with: recipe = $recipe" }

View File

@@ -1,12 +0,0 @@
package gq.kirmanak.mealient.data.add.impl
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import retrofit2.http.Body
import retrofit2.http.POST
interface AddRecipeService {
@POST("/api/recipes/create")
suspend fun addRecipe(@Body addRecipeRequest: AddRecipeRequest): String
}

View File

@@ -4,5 +4,5 @@ interface AuthDataSource {
/** /**
* Tries to acquire authentication token using the provided credentials * Tries to acquire authentication token using the provided credentials
*/ */
suspend fun authenticate(username: String, password: String): String suspend fun authenticate(username: String, password: String, baseUrl: String): String
} }

View File

@@ -1,56 +1,25 @@
package gq.kirmanak.mealient.data.auth.impl package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.data.auth.AuthDataSource import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.network.ErrorDetail import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.data.network.NetworkError.NotMealie
import gq.kirmanak.mealient.data.network.NetworkError.Unauthorized
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.extensions.decodeErrorBody
import gq.kirmanak.mealient.extensions.logAndMapErrors import gq.kirmanak.mealient.extensions.logAndMapErrors
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import retrofit2.Response
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class AuthDataSourceImpl @Inject constructor( class AuthDataSourceImpl @Inject constructor(
private val authServiceFactory: ServiceFactory<AuthService>,
private val json: Json,
private val logger: Logger, private val logger: Logger,
private val mealieDataSource: MealieDataSource,
) : AuthDataSource { ) : AuthDataSource {
override suspend fun authenticate(username: String, password: String): String { override suspend fun authenticate(username: String, password: String, baseUrl: String): String {
logger.v { "authenticate() called with: username = $username, password = $password" } logger.v { "authenticate() called with: username = $username, password = $password" }
val authService = authServiceFactory.provideService() val accessToken = logger.logAndMapErrors(
val response = sendRequest(authService, username, password) block = { mealieDataSource.authenticate(baseUrl, username, password) },
val accessToken = parseToken(response) logProvider = { "sendRequest: can't get token" },
)
logger.v { "authenticate() returned: $accessToken" } logger.v { "authenticate() returned: $accessToken" }
return accessToken return accessToken
} }
private suspend fun sendRequest(
authService: AuthService,
username: String,
password: String
): Response<GetTokenResponse> = logger.logAndMapErrors(
block = { authService.getToken(username = username, password = password) },
logProvider = { "sendRequest: can't get token" },
)
private fun parseToken(
response: Response<GetTokenResponse>
): String = if (response.isSuccessful) {
response.body()?.accessToken ?: throw NotMealie(NullPointerException("Body is null"))
} else {
val cause = HttpException(response)
val errorDetail = json.runCatching<Json, ErrorDetail> { decodeErrorBody(response) }
.onFailure { logger.e(it) { "Can't decode error body" } }
.getOrNull()
throw when (errorDetail?.detail) {
"Unauthorized" -> Unauthorized(cause)
else -> NotMealie(cause)
}
}
} }

View File

@@ -3,6 +3,7 @@ package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.data.auth.AuthDataSource import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -14,6 +15,7 @@ import javax.inject.Singleton
class AuthRepoImpl @Inject constructor( class AuthRepoImpl @Inject constructor(
private val authStorage: AuthStorage, private val authStorage: AuthStorage,
private val authDataSource: AuthDataSource, private val authDataSource: AuthDataSource,
private val baseURLStorage: BaseURLStorage,
private val logger: Logger, private val logger: Logger,
) : AuthRepo { ) : AuthRepo {
@@ -22,7 +24,7 @@ class AuthRepoImpl @Inject constructor(
override suspend fun authenticate(email: String, password: String) { override suspend fun authenticate(email: String, password: String) {
logger.v { "authenticate() called with: email = $email, password = $password" } logger.v { "authenticate() called with: email = $email, password = $password" }
authDataSource.authenticate(email, password) authDataSource.authenticate(email, password, baseURLStorage.requireBaseURL())
.let { AUTH_HEADER_FORMAT.format(it) } .let { AUTH_HEADER_FORMAT.format(it) }
.let { authStorage.setAuthHeader(it) } .let { authStorage.setAuthHeader(it) }
authStorage.setEmail(email) authStorage.setEmail(email)

View File

@@ -1,15 +0,0 @@
package gq.kirmanak.mealient.data.auth.impl
import retrofit2.Response
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
interface AuthService {
@FormUrlEncoded
@POST("/api/auth/token")
suspend fun getToken(
@Field("username") username: String,
@Field("password") password: String,
): Response<GetTokenResponse>
}

View File

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

View File

@@ -2,7 +2,7 @@ package gq.kirmanak.mealient.data.baseurl.impl
import gq.kirmanak.mealient.data.baseurl.VersionDataSource import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.VersionInfo import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.data.network.ServiceFactory import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper
import gq.kirmanak.mealient.extensions.logAndMapErrors import gq.kirmanak.mealient.extensions.logAndMapErrors
import gq.kirmanak.mealient.extensions.versionInfo import gq.kirmanak.mealient.extensions.versionInfo
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
@@ -11,16 +11,15 @@ import javax.inject.Singleton
@Singleton @Singleton
class VersionDataSourceImpl @Inject constructor( class VersionDataSourceImpl @Inject constructor(
private val serviceFactory: ServiceFactory<VersionService>,
private val logger: Logger, private val logger: Logger,
private val mealieDataSourceWrapper: MealieDataSourceWrapper,
) : VersionDataSource { ) : VersionDataSource {
override suspend fun getVersionInfo(baseUrl: String): VersionInfo { override suspend fun getVersionInfo(baseUrl: String): VersionInfo {
logger.v { "getVersionInfo() called with: baseUrl = $baseUrl" } logger.v { "getVersionInfo() called with: baseUrl = $baseUrl" }
val service = serviceFactory.provideService(baseUrl)
val response = logger.logAndMapErrors( val response = logger.logAndMapErrors(
block = { service.getVersion() }, block = { mealieDataSourceWrapper.getVersionInfo(baseUrl) },
logProvider = { "getVersionInfo: can't request version" } logProvider = { "getVersionInfo: can't request version" }
) )

View File

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

View File

@@ -1,43 +0,0 @@
package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.auth.AuthRepo
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthenticationInterceptor @Inject constructor(
private val authRepo: AuthRepo,
) : Interceptor {
private val authHeader: String?
get() = runBlocking { authRepo.getAuthHeader() }
override fun intercept(chain: Interceptor.Chain): Response {
val currentHeader = authHeader ?: return chain.proceed(chain.request())
val response = proceedWithAuthHeader(chain, currentHeader)
return if (listOf(401, 403).contains(response.code)) {
runBlocking { authRepo.invalidateAuthHeader() }
// Try again with new auth header (if any) or return previous response
authHeader?.let { proceedWithAuthHeader(chain, it) } ?: response
} else {
response
}
}
private fun proceedWithAuthHeader(
chain: Interceptor.Chain,
authHeader: String,
) = chain.proceed(
chain.request()
.newBuilder()
.header(HEADER_NAME, authHeader)
.build()
)
companion object {
private const val HEADER_NAME = "Authorization"
}
}

View File

@@ -0,0 +1,49 @@
package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.datasource.models.*
import gq.kirmanak.mealient.datasource.models.NetworkError
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MealieDataSourceWrapper @Inject constructor(
private val baseURLStorage: BaseURLStorage,
private val authRepo: AuthRepo,
private val mealieDataSource: MealieDataSource,
) {
suspend fun addRecipe(recipe: AddRecipeRequest): String {
val baseUrl = baseURLStorage.requireBaseURL()
return withAuthHeader { token -> addRecipe(baseUrl, token, recipe) }
}
suspend fun getVersionInfo(baseUrl: String): VersionResponse {
return mealieDataSource.getVersionInfo(baseUrl)
}
suspend fun requestRecipes(
start: Int = 0,
limit: Int = 9999,
): List<GetRecipeSummaryResponse> {
val baseUrl = baseURLStorage.requireBaseURL()
return withAuthHeader { token -> requestRecipes(baseUrl, token, start, limit) }
}
suspend fun requestRecipeInfo(slug: String): GetRecipeResponse {
val baseUrl = baseURLStorage.requireBaseURL()
return withAuthHeader { token -> requestRecipeInfo(baseUrl, token, slug) }
}
private suspend inline fun <T> withAuthHeader(block: MealieDataSource.(String?) -> T): T =
mealieDataSource.runCatching { block(authRepo.getAuthHeader()) }.getOrElse {
if (it is NetworkError.Unauthorized) {
authRepo.invalidateAuthHeader()
mealieDataSource.block(authRepo.getAuthHeader())
} else {
throw it
}
}
}

View File

@@ -1,28 +0,0 @@
package gq.kirmanak.mealient.data.network
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
class RetrofitBuilder(
private val okHttpClient: OkHttpClient,
private val json: Json,
private val logger: Logger,
) {
@OptIn(ExperimentalSerializationApi::class)
fun buildRetrofit(baseUrl: String): Retrofit {
logger.v { "buildRetrofit() called with: baseUrl = $baseUrl" }
val contentType = "application/json".toMediaType()
val converterFactory = json.asConverterFactory(contentType)
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(converterFactory)
.build()
}
}

View File

@@ -1,37 +0,0 @@
package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
inline fun <reified T> RetrofitBuilder.createServiceFactory(
baseURLStorage: BaseURLStorage,
logger: Logger
) =
RetrofitServiceFactory(T::class.java, this, baseURLStorage, logger)
class RetrofitServiceFactory<T>(
private val serviceClass: Class<T>,
private val retrofitBuilder: RetrofitBuilder,
private val baseURLStorage: BaseURLStorage,
private val logger: Logger,
) : ServiceFactory<T> {
private val cache: MutableMap<String, T> = mutableMapOf()
override suspend fun provideService(baseUrl: String?): T = runCatchingExceptCancel {
logger.v { "provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}" }
val url = baseUrl ?: baseURLStorage.requireBaseURL()
synchronized(cache) { cache[url] ?: createService(url, serviceClass) }
}.getOrElse {
logger.e(it) { "provideService: can't provide service for $baseUrl" }
throw NetworkError.MalformedUrl(it)
}
private fun createService(url: String, serviceClass: Class<T>): T {
logger.v { "createService() called with: url = $url, serviceClass = ${serviceClass.simpleName}" }
val service = retrofitBuilder.buildRetrofit(url).create(serviceClass)
cache[url] = service
return service
}
}

View File

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

View File

@@ -1,10 +1,10 @@
package gq.kirmanak.mealient.data.recipes.db package gq.kirmanak.mealient.data.recipes.db
import androidx.paging.PagingSource import androidx.paging.PagingSource
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import gq.kirmanak.mealient.database.recipe.entity.FullRecipeInfo import gq.kirmanak.mealient.database.recipe.entity.FullRecipeInfo
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
interface RecipeStorage { interface RecipeStorage {
suspend fun saveRecipes(recipes: List<GetRecipeSummaryResponse>) suspend fun saveRecipes(recipes: List<GetRecipeSummaryResponse>)

View File

@@ -2,11 +2,11 @@ package gq.kirmanak.mealient.data.recipes.db
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.room.withTransaction import androidx.room.withTransaction
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import gq.kirmanak.mealient.database.AppDb import gq.kirmanak.mealient.database.AppDb
import gq.kirmanak.mealient.database.recipe.RecipeDao import gq.kirmanak.mealient.database.recipe.RecipeDao
import gq.kirmanak.mealient.database.recipe.entity.* import gq.kirmanak.mealient.database.recipe.entity.*
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
import gq.kirmanak.mealient.extensions.recipeEntity import gq.kirmanak.mealient.extensions.recipeEntity
import gq.kirmanak.mealient.extensions.toRecipeEntity import gq.kirmanak.mealient.extensions.toRecipeEntity
import gq.kirmanak.mealient.extensions.toRecipeIngredientEntity import gq.kirmanak.mealient.extensions.toRecipeIngredientEntity

View File

@@ -1,7 +1,7 @@
package gq.kirmanak.mealient.data.recipes.network package gq.kirmanak.mealient.data.recipes.network
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
interface RecipeDataSource { interface RecipeDataSource {
suspend fun requestRecipes(start: Int = 0, limit: Int = 9999): List<GetRecipeSummaryResponse> suspend fun requestRecipes(start: Int = 0, limit: Int = 9999): List<GetRecipeSummaryResponse>

View File

@@ -1,34 +1,30 @@
package gq.kirmanak.mealient.data.recipes.network package gq.kirmanak.mealient.data.recipes.network
import gq.kirmanak.mealient.data.network.ServiceFactory import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class RecipeDataSourceImpl @Inject constructor( class RecipeDataSourceImpl @Inject constructor(
private val recipeServiceFactory: ServiceFactory<RecipeService>,
private val logger: Logger, private val logger: Logger,
private val mealieDataSourceWrapper: MealieDataSourceWrapper,
) : RecipeDataSource { ) : RecipeDataSource {
override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> { override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> {
logger.v { "requestRecipes() called with: start = $start, limit = $limit" } logger.v { "requestRecipes() called with: start = $start, limit = $limit" }
val recipeSummary = getRecipeService().getRecipeSummary(start, limit) val recipeSummary = mealieDataSourceWrapper.requestRecipes(start, limit)
logger.v { "requestRecipes() returned: $recipeSummary" } logger.v { "requestRecipes() returned: $recipeSummary" }
return recipeSummary return recipeSummary
} }
override suspend fun requestRecipeInfo(slug: String): GetRecipeResponse { override suspend fun requestRecipeInfo(slug: String): GetRecipeResponse {
logger.v { "requestRecipeInfo() called with: slug = $slug" } logger.v { "requestRecipeInfo() called with: slug = $slug" }
val recipeInfo = getRecipeService().getRecipe(slug) val recipeInfo = mealieDataSourceWrapper.requestRecipeInfo(slug)
logger.v { "requestRecipeInfo() returned: $recipeInfo" } logger.v { "requestRecipeInfo() returned: $recipeInfo" }
return recipeInfo return recipeInfo
} }
private suspend fun getRecipeService(): RecipeService {
logger.v { "getRecipeService() called" }
return recipeServiceFactory.provideService()
}
} }

View File

@@ -1,20 +0,0 @@
package gq.kirmanak.mealient.data.recipes.network
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface RecipeService {
@GET("/api/recipes/summary")
suspend fun getRecipeSummary(
@Query("start") start: Int,
@Query("limit") limit: Int,
): List<GetRecipeSummaryResponse>
@GET("/api/recipes/{recipe_slug}")
suspend fun getRecipe(
@Path("recipe_slug") recipeSlug: String,
): GetRecipeResponse
}

View File

@@ -2,46 +2,20 @@ package gq.kirmanak.mealient.di
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.add.AddRecipeDataSource import gq.kirmanak.mealient.data.add.AddRecipeDataSource
import gq.kirmanak.mealient.data.add.AddRecipeRepo import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.data.add.impl.AddRecipeDataSourceImpl import gq.kirmanak.mealient.data.add.impl.AddRecipeDataSourceImpl
import gq.kirmanak.mealient.data.add.impl.AddRecipeRepoImpl import gq.kirmanak.mealient.data.add.impl.AddRecipeRepoImpl
import gq.kirmanak.mealient.data.add.impl.AddRecipeService
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorage import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorage
import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorageImpl import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorageImpl
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface AddRecipeModule { interface AddRecipeModule {
companion object {
@Provides
@Singleton
fun provideAddRecipeServiceFactory(
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
logger: Logger,
baseURLStorage: BaseURLStorage,
): ServiceFactory<AddRecipeService> {
return RetrofitBuilder(okHttpClient, json, logger).createServiceFactory(
baseURLStorage,
logger
)
}
}
@Binds @Binds
@Singleton @Singleton

View File

@@ -13,16 +13,7 @@ import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl
import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl
import gq.kirmanak.mealient.data.auth.impl.AuthService
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -31,20 +22,6 @@ interface AuthModule {
companion object { companion object {
@Provides
@Singleton
fun provideAuthServiceFactory(
@Named(NO_AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
logger: Logger,
baseURLStorage: BaseURLStorage,
): ServiceFactory<AuthService> {
return RetrofitBuilder(okHttpClient, json, logger).createServiceFactory(
baseURLStorage,
logger
)
}
@Provides @Provides
@Singleton @Singleton
fun provideAccountManager(@ApplicationContext context: Context): AccountManager { fun provideAccountManager(@ApplicationContext context: Context): AccountManager {

View File

@@ -2,44 +2,18 @@ package gq.kirmanak.mealient.di
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionDataSource import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.impl.BaseURLStorageImpl import gq.kirmanak.mealient.data.baseurl.impl.BaseURLStorageImpl
import gq.kirmanak.mealient.data.baseurl.impl.VersionDataSourceImpl import gq.kirmanak.mealient.data.baseurl.impl.VersionDataSourceImpl
import gq.kirmanak.mealient.data.baseurl.impl.VersionService
import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface BaseURLModule { interface BaseURLModule {
companion object {
@Provides
@Singleton
fun provideVersionServiceFactory(
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
logger: Logger,
baseURLStorage: BaseURLStorage,
): ServiceFactory<VersionService> {
return RetrofitBuilder(okHttpClient, json, logger).createServiceFactory(
baseURLStorage,
logger
)
}
}
@Binds @Binds
@Singleton @Singleton
fun bindVersionDataSource(versionDataSourceImpl: VersionDataSourceImpl): VersionDataSource fun bindVersionDataSource(versionDataSourceImpl: VersionDataSourceImpl): VersionDataSource

View File

@@ -8,7 +8,6 @@ import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.io.InputStream import java.io.InputStream
import javax.inject.Named
@EntryPoint @EntryPoint
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -16,7 +15,6 @@ interface GlideModuleEntryPoint {
fun provideLogger(): Logger fun provideLogger(): Logger
@Named(AUTH_OK_HTTP)
fun provideOkHttp(): OkHttpClient fun provideOkHttp(): OkHttpClient
fun provideRecipeLoaderFactory(): ModelLoaderFactory<RecipeSummaryEntity, InputStream> fun provideRecipeLoaderFactory(): ModelLoaderFactory<RecipeSummaryEntity, InputStream>

View File

@@ -1,50 +0,0 @@
package gq.kirmanak.mealient.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.network.AuthenticationInterceptor
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton
const val AUTH_OK_HTTP = "auth"
const val NO_AUTH_OK_HTTP = "noauth"
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
@Named(AUTH_OK_HTTP)
fun createAuthOkHttp(
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
interceptors: Set<@JvmSuppressWildcards Interceptor>,
authenticationInterceptor: AuthenticationInterceptor,
): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(authenticationInterceptor)
.apply { for (interceptor in interceptors) addNetworkInterceptor(interceptor) }
.build()
@Provides
@Singleton
@Named(NO_AUTH_OK_HTTP)
fun createNoAuthOkHttp(
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
interceptors: Set<@JvmSuppressWildcards Interceptor>,
): OkHttpClient = OkHttpClient.Builder()
.apply { for (interceptor in interceptors) addNetworkInterceptor(interceptor) }
.build()
@Provides
@Singleton
fun createJson(): Json = Json {
coerceInputValues = true
ignoreUnknownKeys = true
encodeDefaults = true
}
}

View File

@@ -9,10 +9,6 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.data.recipes.db.RecipeStorageImpl import gq.kirmanak.mealient.data.recipes.db.RecipeStorageImpl
@@ -21,14 +17,9 @@ import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProviderImpl
import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl
import gq.kirmanak.mealient.data.recipes.network.RecipeService
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.recipes.images.RecipeModelLoaderFactory import gq.kirmanak.mealient.ui.recipes.images.RecipeModelLoaderFactory
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import java.io.InputStream import java.io.InputStream
import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -57,20 +48,6 @@ interface RecipeModule {
companion object { companion object {
@Provides
@Singleton
fun provideRecipeServiceFactory(
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
logger: Logger,
baseURLStorage: BaseURLStorage,
): ServiceFactory<RecipeService> {
return RetrofitBuilder(okHttpClient, json, logger).createServiceFactory(
baseURLStorage,
logger
)
}
@Provides @Provides
@Singleton @Singleton
fun provideRecipePagingSourceFactory( fun provideRecipePagingSourceFactory(

View File

@@ -1,25 +1,7 @@
package gq.kirmanak.mealient.extensions package gq.kirmanak.mealient.extensions
import gq.kirmanak.mealient.data.network.NetworkError import gq.kirmanak.mealient.datasource.mapToNetworkError
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import retrofit2.HttpException
import retrofit2.Response
@OptIn(ExperimentalSerializationApi::class)
inline fun <T, reified R> Json.decodeErrorBody(response: Response<T>): R =
checkNotNull(response.errorBody()) { "Can't decode absent error body" }
.byteStream()
.let(::decodeFromStream)
fun Throwable.mapToNetworkError(): NetworkError = when (this) {
is HttpException, is SerializationException -> NetworkError.NotMealie(this)
else -> NetworkError.NoServerConnection(this)
}
inline fun <T> Logger.logAndMapErrors( inline fun <T> Logger.logAndMapErrors(
block: () -> T, block: () -> T,

View File

@@ -1,15 +1,12 @@
package gq.kirmanak.mealient.extensions package gq.kirmanak.mealient.extensions
import gq.kirmanak.mealient.data.baseurl.VersionInfo import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.data.baseurl.impl.VersionResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeIngredientResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeInstructionResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.datasource.models.*
import gq.kirmanak.mealient.datastore.recipe.AddRecipeDraft
fun GetRecipeResponse.toRecipeEntity() = RecipeEntity( fun GetRecipeResponse.toRecipeEntity() = RecipeEntity(
remoteId = remoteId, remoteId = remoteId,
@@ -46,3 +43,25 @@ fun GetRecipeSummaryResponse.recipeEntity() = RecipeSummaryEntity(
) )
fun VersionResponse.versionInfo() = VersionInfo(production, version, demoStatus) fun VersionResponse.versionInfo() = VersionInfo(production, version, demoStatus)
fun AddRecipeDraft.toAddRecipeRequest() = AddRecipeRequest(
name = recipeName,
description = recipeDescription,
recipeYield = recipeYield,
recipeIngredient = recipeIngredients.map { AddRecipeIngredient(note = it) },
recipeInstructions = recipeInstructions.map { AddRecipeInstruction(text = it) },
settings = AddRecipeSettings(
public = isRecipePublic,
disableComments = areCommentsDisabled,
)
)
fun AddRecipeRequest.toDraft(): AddRecipeDraft = AddRecipeDraft(
recipeName = name,
recipeDescription = description,
recipeYield = recipeYield,
recipeInstructions = recipeInstructions.map { it.text },
recipeIngredients = recipeIngredient.map { it.note },
isRecipePublic = settings.public,
areCommentsDisabled = settings.disableComments,
)

View File

@@ -12,12 +12,12 @@ import androidx.fragment.app.viewModels
import by.kirich1409.viewbindingdelegate.viewBinding import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.add.models.AddRecipeIngredient
import gq.kirmanak.mealient.data.add.models.AddRecipeInstruction
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import gq.kirmanak.mealient.data.add.models.AddRecipeSettings
import gq.kirmanak.mealient.databinding.FragmentAddRecipeBinding import gq.kirmanak.mealient.databinding.FragmentAddRecipeBinding
import gq.kirmanak.mealient.databinding.ViewSingleInputBinding import gq.kirmanak.mealient.databinding.ViewSingleInputBinding
import gq.kirmanak.mealient.datasource.models.AddRecipeIngredient
import gq.kirmanak.mealient.datasource.models.AddRecipeInstruction
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.datasource.models.AddRecipeSettings
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.extensions.collectWhenViewResumed import gq.kirmanak.mealient.extensions.collectWhenViewResumed
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger

View File

@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.add.AddRecipeRepo import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel

View File

@@ -9,8 +9,8 @@ import androidx.navigation.fragment.findNavController
import by.kirich1409.viewbindingdelegate.viewBinding import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
import gq.kirmanak.mealient.datasource.models.NetworkError
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState import gq.kirmanak.mealient.ui.OperationUiState

View File

@@ -9,8 +9,8 @@ import androidx.navigation.fragment.findNavController
import by.kirich1409.viewbindingdelegate.viewBinding import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding
import gq.kirmanak.mealient.datasource.models.NetworkError
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState import gq.kirmanak.mealient.ui.OperationUiState

View File

@@ -1,12 +1,12 @@
package gq.kirmanak.mealient.ui.recipes.images package gq.kirmanak.mealient.ui.recipes.images
import com.bumptech.glide.load.Options import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.GlideUrl import com.bumptech.glide.load.model.*
import com.bumptech.glide.load.model.ModelCache
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.stream.BaseGlideUrlLoader import com.bumptech.glide.load.model.stream.BaseGlideUrlLoader
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.datasource.DataSourceModule.Companion.AUTHORIZATION_HEADER_NAME
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import java.io.InputStream import java.io.InputStream
@@ -16,6 +16,7 @@ import javax.inject.Singleton
class RecipeModelLoader private constructor( class RecipeModelLoader private constructor(
private val recipeImageUrlProvider: RecipeImageUrlProvider, private val recipeImageUrlProvider: RecipeImageUrlProvider,
private val logger: Logger, private val logger: Logger,
private val authRepo: AuthRepo,
concreteLoader: ModelLoader<GlideUrl, InputStream>, concreteLoader: ModelLoader<GlideUrl, InputStream>,
cache: ModelCache<RecipeSummaryEntity, GlideUrl>, cache: ModelCache<RecipeSummaryEntity, GlideUrl>,
) : BaseGlideUrlLoader<RecipeSummaryEntity>(concreteLoader, cache) { ) : BaseGlideUrlLoader<RecipeSummaryEntity>(concreteLoader, cache) {
@@ -24,12 +25,13 @@ class RecipeModelLoader private constructor(
class Factory @Inject constructor( class Factory @Inject constructor(
private val recipeImageUrlProvider: RecipeImageUrlProvider, private val recipeImageUrlProvider: RecipeImageUrlProvider,
private val logger: Logger, private val logger: Logger,
private val authRepo: AuthRepo,
) { ) {
fun build( fun build(
concreteLoader: ModelLoader<GlideUrl, InputStream>, concreteLoader: ModelLoader<GlideUrl, InputStream>,
cache: ModelCache<RecipeSummaryEntity, GlideUrl>, cache: ModelCache<RecipeSummaryEntity, GlideUrl>,
) = RecipeModelLoader(recipeImageUrlProvider, logger, concreteLoader, cache) ) = RecipeModelLoader(recipeImageUrlProvider, logger, authRepo, concreteLoader, cache)
} }
@@ -44,4 +46,20 @@ class RecipeModelLoader private constructor(
logger.v { "getUrl() called with: model = $model, width = $width, height = $height, options = $options" } logger.v { "getUrl() called with: model = $model, width = $width, height = $height, options = $options" }
return runBlocking { recipeImageUrlProvider.generateImageUrl(model?.slug) } return runBlocking { recipeImageUrlProvider.generateImageUrl(model?.slug) }
} }
override fun getHeaders(
model: RecipeSummaryEntity?,
width: Int,
height: Int,
options: Options?
): Headers? {
val authorization = runBlocking { authRepo.getAuthHeader() }
return if (authorization.isNullOrBlank()) {
super.getHeaders(model, width, height, options)
} else {
LazyHeaders.Builder()
.setHeader(AUTHORIZATION_HEADER_NAME, authorization)
.build()
}
}
} }

1
datasource/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,45 @@
plugins {
id("gq.kirmanak.mealient.library")
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
defaultConfig {
buildConfigField("Boolean", "LOG_NETWORK", "false")
}
namespace = "gq.kirmanak.mealient.datasource"
}
dependencies {
implementation(project(":logging"))
implementation(libs.google.dagger.hiltAndroid)
kapt(libs.google.dagger.hiltCompiler)
kaptTest(libs.google.dagger.hiltAndroidCompiler)
testImplementation(libs.google.dagger.hiltAndroidTesting)
implementation(libs.jetbrains.kotlinx.datetime)
implementation(libs.jetbrains.kotlinx.serialization)
implementation(libs.squareup.retrofit)
implementation(libs.jakewharton.retrofitSerialization)
implementation(platform(libs.okhttp3.bom))
implementation(libs.okhttp3.okhttp)
debugImplementation(libs.okhttp3.loggingInterceptor)
implementation(libs.jetbrains.kotlinx.coroutinesAndroid)
testImplementation(libs.jetbrains.kotlinx.coroutinesTest)
testImplementation(libs.androidx.test.junit)
testImplementation(libs.google.truth)
testImplementation(libs.io.mockk)
debugImplementation(libs.chuckerteam.chucker)
}

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.di package gq.kirmanak.mealient
import android.content.Context import android.content.Context
import com.chuckerteam.chucker.api.ChuckerCollector import com.chuckerteam.chucker.api.ChuckerCollector
@@ -10,7 +10,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.BuildConfig import gq.kirmanak.mealient.datasource.BuildConfig
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor

View File

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

View File

@@ -0,0 +1,41 @@
package gq.kirmanak.mealient.datasource
import android.content.Context
import android.os.StatFs
import dagger.hilt.android.qualifiers.ApplicationContext
import gq.kirmanak.mealient.logging.Logger
import okhttp3.Cache
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CacheBuilderImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val logger: Logger,
) : CacheBuilder {
override fun buildCache(): Cache {
val dir = findCacheDir()
return Cache(dir, calculateDiskCacheSize(dir))
}
private fun findCacheDir(): File = File(context.cacheDir, "okhttp")
private fun calculateDiskCacheSize(dir: File): Long = dir.runCatching {
StatFs(absolutePath).let {
it.blockCountLong * it.blockSizeLong * AVAILABLE_SPACE_PERCENT / 100
}
}
.onFailure { logger.e(it) { "Can't get available space" } }
.getOrNull()
?.coerceAtLeast(MIN_OKHTTP_CACHE_SIZE)
?.coerceAtMost(MAX_OKHTTP_CACHE_SIZE)
?: MIN_OKHTTP_CACHE_SIZE
companion object {
private const val MIN_OKHTTP_CACHE_SIZE = 5 * 1024 * 1024L // 5MB
private const val MAX_OKHTTP_CACHE_SIZE = 50 * 1024 * 1024L // 50MB
private const val AVAILABLE_SPACE_PERCENT = 2
}
}

View File

@@ -0,0 +1,68 @@
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 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
@InstallIn(SingletonComponent::class)
interface DataSourceModule {
companion object {
const val AUTHORIZATION_HEADER_NAME = "Authorization"
@Provides
@Singleton
fun provideJson(): Json = Json {
coerceInputValues = true
ignoreUnknownKeys = true
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 =
okHttpBuilder.buildOkHttp()
@Provides
@Singleton
fun provideRetrofit(retrofitBuilder: RetrofitBuilder): Retrofit =
retrofitBuilder.buildRetrofit("https://beta.mealie.io/")
@Provides
@Singleton
fun provideMealieService(retrofit: Retrofit): MealieService =
retrofit.create()
}
@Binds
@Singleton
fun bindCacheBuilder(cacheBuilderImpl: CacheBuilderImpl): CacheBuilder
@Binds
@Singleton
fun bindOkHttpBuilder(okHttpBuilderImpl: OkHttpBuilderImpl): OkHttpBuilder
@Binds
@Singleton
fun bindMealieDataSource(mealientDataSourceImpl: MealieDataSourceImpl): MealieDataSource
}

View File

@@ -0,0 +1,41 @@
package gq.kirmanak.mealient.datasource
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
import gq.kirmanak.mealient.datasource.models.VersionResponse
interface MealieDataSource {
suspend fun addRecipe(
baseUrl: String,
token: String?,
recipe: AddRecipeRequest,
): String
/**
* Tries to acquire authentication token using the provided credentials
*/
suspend fun authenticate(
baseUrl: String,
username: String,
password: String,
): String
suspend fun getVersionInfo(
baseUrl: String,
): VersionResponse
suspend fun requestRecipes(
baseUrl: String,
token: String?,
start: Int = 0,
limit: Int = 9999,
): List<GetRecipeSummaryResponse>
suspend fun requestRecipeInfo(
baseUrl: String,
token: String?,
slug: String,
): GetRecipeResponse
}

View File

@@ -0,0 +1,106 @@
package gq.kirmanak.mealient.datasource
import gq.kirmanak.mealient.datasource.models.*
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import retrofit2.HttpException
import retrofit2.Response
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MealieDataSourceImpl @Inject constructor(
private val logger: Logger,
private val mealieService: MealieService,
private val json: Json,
) : MealieDataSource {
override suspend fun addRecipe(
baseUrl: String, token: String?, recipe: AddRecipeRequest
): String {
logger.v { "addRecipe() called with: baseUrl = $baseUrl, token = $token, recipe = $recipe" }
return mealieService.runCatching { addRecipe("$baseUrl/api/recipes/create", token, recipe) }
.onFailure { logger.e(it) { "addRecipe() request failed with: baseUrl = $baseUrl, token = $token, recipe = $recipe" } }
.onSuccess { logger.d { "addRecipe() request succeeded with: baseUrl = $baseUrl, token = $token, recipe = $recipe" } }
.getOrThrowUnauthorized()
}
override suspend fun authenticate(
baseUrl: String, username: String, password: String
): String {
logger.v { "authenticate() called with: baseUrl = $baseUrl, username = $username, password = $password" }
return mealieService.runCatching { getToken("$baseUrl/api/auth/token", username, password) }
.onFailure { logger.e(it) { "authenticate() request failed with: baseUrl = $baseUrl, username = $username, password = $password" } }
.onSuccess { logger.d { "authenticate() request succeeded with: baseUrl = $baseUrl, username = $username, password = $password" } }
.mapCatching { parseToken(it) }
.getOrThrowUnauthorized()
}
override suspend fun getVersionInfo(baseUrl: String): VersionResponse {
logger.v { "getVersionInfo() called with: baseUrl = $baseUrl" }
return mealieService.runCatching { getVersion("$baseUrl/api/debug/version") }
.onFailure { logger.e(it) { "getVersionInfo() request failed with: baseUrl = $baseUrl" } }
.onSuccess { logger.d { "getVersionInfo() request succeeded with: baseUrl = $baseUrl" } }
.getOrThrowUnauthorized()
}
override suspend fun requestRecipes(
baseUrl: String, token: String?, start: Int, limit: Int
): List<GetRecipeSummaryResponse> {
logger.v { "requestRecipes() called with: baseUrl = $baseUrl, token = $token, start = $start, limit = $limit" }
return mealieService.runCatching {
getRecipeSummary("$baseUrl/api/recipes/summary", token, start, limit)
}
.onFailure { logger.e(it) { "requestRecipes() request failed with: baseUrl = $baseUrl, token = $token, start = $start, limit = $limit" } }
.onSuccess { logger.d { "requestRecipes() request succeeded with: baseUrl = $baseUrl, token = $token, start = $start, limit = $limit" } }
.getOrThrowUnauthorized()
}
override suspend fun requestRecipeInfo(
baseUrl: String, token: String?, slug: String
): GetRecipeResponse {
logger.v { "requestRecipeInfo() called with: baseUrl = $baseUrl, token = $token, slug = $slug" }
return mealieService.runCatching { getRecipe("$baseUrl/api/recipes/$slug", token) }
.onFailure { logger.e(it) { "requestRecipeInfo() request failed with: baseUrl = $baseUrl, token = $token, slug = $slug" } }
.onSuccess { logger.d { "requestRecipeInfo() request succeeded with: baseUrl = $baseUrl, token = $token, slug = $slug" } }
.getOrThrowUnauthorized()
}
private fun parseToken(
response: Response<GetTokenResponse>
): String = if (response.isSuccessful) {
response.body()?.accessToken
?: throw NetworkError.NotMealie(NullPointerException("Body is null"))
} else {
val cause = HttpException(response)
val errorDetail = json.runCatching<Json, ErrorDetail> { decodeErrorBody(response) }
.onFailure { logger.e(it) { "Can't decode error body" } }
.getOrNull()
throw when (errorDetail?.detail) {
"Unauthorized" -> NetworkError.Unauthorized(cause)
else -> NetworkError.NotMealie(cause)
}
}
}
private fun <T> Result<T>.getOrThrowUnauthorized(): T = getOrElse {
throw if (it is HttpException && it.code() in listOf(401, 403)) {
NetworkError.Unauthorized(it)
} else {
it
}
}
@OptIn(ExperimentalSerializationApi::class)
private inline fun <T, reified R> Json.decodeErrorBody(response: Response<T>): R {
val responseBody = checkNotNull(response.errorBody()) { "Can't decode absent error body" }
return decodeFromStream(responseBody.byteStream())
}
fun Throwable.mapToNetworkError(): NetworkError = when (this) {
is HttpException, is SerializationException -> NetworkError.NotMealie(this)
else -> NetworkError.NoServerConnection(this)
}

View File

@@ -0,0 +1,43 @@
package gq.kirmanak.mealient.datasource
import gq.kirmanak.mealient.datasource.DataSourceModule.Companion.AUTHORIZATION_HEADER_NAME
import gq.kirmanak.mealient.datasource.models.*
import retrofit2.Response
import retrofit2.http.*
interface MealieService {
@FormUrlEncoded
@POST
suspend fun getToken(
@Url url: String,
@Field("username") username: String,
@Field("password") password: String,
): Response<GetTokenResponse>
@POST
suspend fun addRecipe(
@Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
@Body addRecipeRequest: AddRecipeRequest,
): String
@GET
suspend fun getVersion(
@Url url: String,
): VersionResponse
@GET
suspend fun getRecipeSummary(
@Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
@Query("start") start: Int,
@Query("limit") limit: Int,
): List<GetRecipeSummaryResponse>
@GET
suspend fun getRecipe(
@Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
): GetRecipeResponse
}

View File

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

View File

@@ -0,0 +1,19 @@
package gq.kirmanak.mealient.datasource
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class OkHttpBuilderImpl @Inject constructor(
private val cacheBuilder: CacheBuilder,
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
private val interceptors: Set<@JvmSuppressWildcards Interceptor>,
) : OkHttpBuilder {
override fun buildOkHttp(): OkHttpClient = OkHttpClient.Builder()
.apply { interceptors.forEach(::addNetworkInterceptor) }
.cache(cacheBuilder.buildCache())
.build()
}

View File

@@ -0,0 +1,25 @@
package gq.kirmanak.mealient.datasource
import gq.kirmanak.mealient.logging.Logger
import okhttp3.OkHttpClient
import retrofit2.Converter.Factory
import retrofit2.Retrofit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
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

@@ -1,6 +1,5 @@
package gq.kirmanak.mealient.data.add.models package gq.kirmanak.mealient.datasource.models
import gq.kirmanak.mealient.datastore.recipe.AddRecipeDraft
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -20,29 +19,7 @@ data class AddRecipeRequest(
@SerialName("extras") val extras: Map<String, String> = emptyMap(), @SerialName("extras") val extras: Map<String, String> = emptyMap(),
@SerialName("assets") val assets: List<String> = emptyList(), @SerialName("assets") val assets: List<String> = emptyList(),
@SerialName("settings") val settings: AddRecipeSettings = AddRecipeSettings(), @SerialName("settings") val settings: AddRecipeSettings = AddRecipeSettings(),
) {
constructor(input: AddRecipeDraft) : this(
name = input.recipeName,
description = input.recipeDescription,
recipeYield = input.recipeYield,
recipeIngredient = input.recipeIngredients.map { AddRecipeIngredient(note = it) },
recipeInstructions = input.recipeInstructions.map { AddRecipeInstruction(text = it) },
settings = AddRecipeSettings(
public = input.isRecipePublic,
disableComments = input.areCommentsDisabled,
) )
)
fun toDraft(): AddRecipeDraft = AddRecipeDraft(
recipeName = name,
recipeDescription = description,
recipeYield = recipeYield,
recipeInstructions = recipeInstructions.map { it.text },
recipeIngredients = recipeIngredient.map { it.note },
isRecipePublic = settings.public,
areCommentsDisabled = settings.disableComments,
)
}
@Serializable @Serializable
data class AddRecipeSettings( data class AddRecipeSettings(

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.network package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.recipes.network.response package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.recipes.network.response package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.recipes.network.response package gq.kirmanak.mealient.datasource.models
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.recipes.network.response package gq.kirmanak.mealient.datasource.models
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.auth.impl package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.network package gq.kirmanak.mealient.datasource.models
sealed class NetworkError(cause: Throwable) : RuntimeException(cause) { sealed class NetworkError(cause: Throwable) : RuntimeException(cause) {
class Unauthorized(cause: Throwable) : NetworkError(cause) class Unauthorized(cause: Throwable) : NetworkError(cause)

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.baseurl.impl package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.di package gq.kirmanak.mealient
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides

View File

@@ -23,3 +23,4 @@ include(":app")
include(":database") include(":database")
include(":datastore") include(":datastore")
include(":logging") include(":logging")
include(":datasource")