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
+1 -15
View File
@@ -8,7 +8,6 @@ plugins {
id("kotlin-kapt")
id("androidx.navigation.safeargs.kotlin")
id("dagger.hilt.android.plugin")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
alias(libs.plugins.appsweep)
@@ -19,8 +18,6 @@ android {
applicationId = "gq.kirmanak.mealient"
versionCode = 13
versionName = "0.2.4"
buildConfigField("Boolean", "LOG_NETWORK", "false")
}
signingConfigs {
@@ -68,6 +65,7 @@ dependencies {
implementation(project(":database"))
implementation(project(":datastore"))
implementation(project(":datasource"))
implementation(project(":logging"))
implementation(libs.android.material.material)
@@ -92,16 +90,6 @@ dependencies {
kaptTest(libs.google.dagger.hiltAndroidCompiler)
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)
testImplementation(libs.androidx.paging.commonKtx)
@@ -137,6 +125,4 @@ dependencies {
testImplementation(libs.io.mockk)
debugImplementation(libs.squareup.leakcanary)
debugImplementation(libs.chuckerteam.chucker)
}
@@ -1,48 +0,0 @@
package gq.kirmanak.mealient.di
import android.content.Context
import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor
import com.chuckerteam.chucker.api.RetentionManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.BuildConfig
import gq.kirmanak.mealient.logging.Logger
import okhttp3.Interceptor
import okhttp3.logging.HttpLoggingInterceptor
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DebugModule {
@Provides
@Singleton
@IntoSet
fun provideLoggingInterceptor(logger: Logger): Interceptor {
val interceptor = HttpLoggingInterceptor { message -> logger.v(tag = "OkHttp") { message } }
interceptor.level = when {
BuildConfig.LOG_NETWORK -> HttpLoggingInterceptor.Level.BODY
else -> HttpLoggingInterceptor.Level.BASIC
}
return interceptor
}
@Provides
@Singleton
@IntoSet
fun provideChuckerInterceptor(@ApplicationContext context: Context): Interceptor {
val collector = ChuckerCollector(
context = context,
showNotification = true,
retentionPeriod = RetentionManager.Period.ONE_HOUR,
)
return ChuckerInterceptor.Builder(context)
.collector(collector)
.alwaysReadResponseBody(true)
.build()
}
}
@@ -1,8 +1,7 @@
package gq.kirmanak.mealient.data.add
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
interface AddRecipeDataSource {
suspend fun addRecipe(recipe: AddRecipeRequest): String
}
@@ -1,6 +1,6 @@
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
interface AddRecipeRepo {
@@ -1,8 +1,8 @@
package gq.kirmanak.mealient.data.add.impl
import gq.kirmanak.mealient.data.add.AddRecipeDataSource
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.extensions.logAndMapErrors
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
@@ -10,15 +10,14 @@ import javax.inject.Singleton
@Singleton
class AddRecipeDataSourceImpl @Inject constructor(
private val addRecipeServiceFactory: ServiceFactory<AddRecipeService>,
private val logger: Logger,
private val mealieDataSourceWrapper: MealieDataSourceWrapper,
) : AddRecipeDataSource {
override suspend fun addRecipe(recipe: AddRecipeRequest): String {
logger.v { "addRecipe() called with: recipe = $recipe" }
val service = addRecipeServiceFactory.provideService()
val response = logger.logAndMapErrors(
block = { service.addRecipe(recipe) },
block = { mealieDataSourceWrapper.addRecipe(recipe) },
logProvider = { "addRecipe: can't add recipe" }
)
logger.v { "addRecipe() response = $response" }
@@ -2,8 +2,10 @@ package gq.kirmanak.mealient.data.add.impl
import gq.kirmanak.mealient.data.add.AddRecipeDataSource
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.extensions.toAddRecipeRequest
import gq.kirmanak.mealient.extensions.toDraft
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
@@ -19,7 +21,7 @@ class AddRecipeRepoImpl @Inject constructor(
) : AddRecipeRepo {
override val addRecipeRequestFlow: Flow<AddRecipeRequest>
get() = addRecipeStorage.updates.map { AddRecipeRequest(it) }
get() = addRecipeStorage.updates.map { it.toAddRecipeRequest() }
override suspend fun preserve(recipe: AddRecipeRequest) {
logger.v { "preserveRecipe() called with: recipe = $recipe" }
@@ -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
}
@@ -1,77 +0,0 @@
package gq.kirmanak.mealient.data.add.models
import gq.kirmanak.mealient.datastore.recipe.AddRecipeDraft
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AddRecipeRequest(
@SerialName("name") val name: String = "",
@SerialName("description") val description: String = "",
@SerialName("image") val image: String = "",
@SerialName("recipeYield") val recipeYield: String = "",
@SerialName("recipeIngredient") val recipeIngredient: List<AddRecipeIngredient> = emptyList(),
@SerialName("recipeInstructions") val recipeInstructions: List<AddRecipeInstruction> = emptyList(),
@SerialName("slug") val slug: String = "",
@SerialName("filePath") val filePath: String = "",
@SerialName("tags") val tags: List<String> = emptyList(),
@SerialName("categories") val categories: List<String> = emptyList(),
@SerialName("notes") val notes: List<AddRecipeNote> = emptyList(),
@SerialName("extras") val extras: Map<String, String> = emptyMap(),
@SerialName("assets") val assets: List<String> = emptyList(),
@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
data class AddRecipeSettings(
@SerialName("disableAmount") val disableAmount: Boolean = true,
@SerialName("disableComments") val disableComments: Boolean = false,
@SerialName("landscapeView") val landscapeView: Boolean = true,
@SerialName("public") val public: Boolean = true,
@SerialName("showAssets") val showAssets: Boolean = true,
@SerialName("showNutrition") val showNutrition: Boolean = true,
)
@Serializable
data class AddRecipeNote(
@SerialName("title") val title: String = "",
@SerialName("text") val text: String = "",
)
@Serializable
data class AddRecipeInstruction(
@SerialName("title") val title: String = "",
@SerialName("text") val text: String = "",
)
@Serializable
data class AddRecipeIngredient(
@SerialName("disableAmount") val disableAmount: Boolean = true,
@SerialName("food") val food: String? = null,
@SerialName("note") val note: String = "",
@SerialName("quantity") val quantity: Int = 1,
@SerialName("title") val title: String? = null,
@SerialName("unit") val unit: String? = null,
)
@@ -4,5 +4,5 @@ interface AuthDataSource {
/**
* 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
}
@@ -1,56 +1,25 @@
package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.network.ErrorDetail
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.datasource.MealieDataSource
import gq.kirmanak.mealient.extensions.logAndMapErrors
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import retrofit2.Response
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthDataSourceImpl @Inject constructor(
private val authServiceFactory: ServiceFactory<AuthService>,
private val json: Json,
private val logger: Logger,
private val mealieDataSource: MealieDataSource,
) : 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" }
val authService = authServiceFactory.provideService()
val response = sendRequest(authService, username, password)
val accessToken = parseToken(response)
val accessToken = logger.logAndMapErrors(
block = { mealieDataSource.authenticate(baseUrl, username, password) },
logProvider = { "sendRequest: can't get token" },
)
logger.v { "authenticate() returned: $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)
}
}
}
@@ -3,6 +3,7 @@ package gq.kirmanak.mealient.data.auth.impl
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.BaseURLStorage
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow
@@ -14,6 +15,7 @@ import javax.inject.Singleton
class AuthRepoImpl @Inject constructor(
private val authStorage: AuthStorage,
private val authDataSource: AuthDataSource,
private val baseURLStorage: BaseURLStorage,
private val logger: Logger,
) : AuthRepo {
@@ -22,7 +24,7 @@ class AuthRepoImpl @Inject constructor(
override suspend fun authenticate(email: String, password: String) {
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 { authStorage.setAuthHeader(it) }
authStorage.setEmail(email)
@@ -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>
}
@@ -1,7 +0,0 @@
package gq.kirmanak.mealient.data.auth.impl
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetTokenResponse(@SerialName("access_token") val accessToken: String)
@@ -1,9 +1,6 @@
package gq.kirmanak.mealient.data.baseurl
import gq.kirmanak.mealient.data.network.NetworkError
interface VersionDataSource {
@Throws(NetworkError::class)
suspend fun getVersionInfo(baseUrl: String): VersionInfo
}
@@ -2,7 +2,7 @@ package gq.kirmanak.mealient.data.baseurl.impl
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
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.versionInfo
import gq.kirmanak.mealient.logging.Logger
@@ -11,16 +11,15 @@ import javax.inject.Singleton
@Singleton
class VersionDataSourceImpl @Inject constructor(
private val serviceFactory: ServiceFactory<VersionService>,
private val logger: Logger,
private val mealieDataSourceWrapper: MealieDataSourceWrapper,
) : VersionDataSource {
override suspend fun getVersionInfo(baseUrl: String): VersionInfo {
logger.v { "getVersionInfo() called with: baseUrl = $baseUrl" }
val service = serviceFactory.provideService(baseUrl)
val response = logger.logAndMapErrors(
block = { service.getVersion() },
block = { mealieDataSourceWrapper.getVersionInfo(baseUrl) },
logProvider = { "getVersionInfo: can't request version" }
)
@@ -1,14 +0,0 @@
package gq.kirmanak.mealient.data.baseurl.impl
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class VersionResponse(
@SerialName("production")
val production: Boolean,
@SerialName("version")
val version: String,
@SerialName("demoStatus")
val demoStatus: Boolean,
)
@@ -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
}
@@ -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"
}
}
@@ -1,7 +0,0 @@
package gq.kirmanak.mealient.data.network
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ErrorDetail(@SerialName("detail") val detail: String? = null)
@@ -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
}
}
}
@@ -1,8 +0,0 @@
package gq.kirmanak.mealient.data.network
sealed class NetworkError(cause: Throwable) : RuntimeException(cause) {
class Unauthorized(cause: Throwable) : NetworkError(cause)
class NoServerConnection(cause: Throwable) : NetworkError(cause)
class NotMealie(cause: Throwable) : NetworkError(cause)
class MalformedUrl(cause: Throwable) : NetworkError(cause)
}
@@ -1,28 +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()
}
}
@@ -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
}
}
@@ -1,6 +0,0 @@
package gq.kirmanak.mealient.data.network
interface ServiceFactory<T> {
suspend fun provideService(baseUrl: String? = null): T
}
@@ -1,10 +1,10 @@
package gq.kirmanak.mealient.data.recipes.db
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.RecipeSummaryEntity
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
interface RecipeStorage {
suspend fun saveRecipes(recipes: List<GetRecipeSummaryResponse>)
@@ -2,11 +2,11 @@ package gq.kirmanak.mealient.data.recipes.db
import androidx.paging.PagingSource
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.recipe.RecipeDao
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.toRecipeEntity
import gq.kirmanak.mealient.extensions.toRecipeIngredientEntity
@@ -1,7 +1,7 @@
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 gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
interface RecipeDataSource {
suspend fun requestRecipes(start: Int = 0, limit: Int = 9999): List<GetRecipeSummaryResponse>
@@ -1,34 +1,30 @@
package gq.kirmanak.mealient.data.recipes.network
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecipeDataSourceImpl @Inject constructor(
private val recipeServiceFactory: ServiceFactory<RecipeService>,
private val logger: Logger,
private val mealieDataSourceWrapper: MealieDataSourceWrapper,
) : RecipeDataSource {
override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> {
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" }
return recipeSummary
}
override suspend fun requestRecipeInfo(slug: String): GetRecipeResponse {
logger.v { "requestRecipeInfo() called with: slug = $slug" }
val recipeInfo = getRecipeService().getRecipe(slug)
val recipeInfo = mealieDataSourceWrapper.requestRecipeInfo(slug)
logger.v { "requestRecipeInfo() returned: $recipeInfo" }
return recipeInfo
}
private suspend fun getRecipeService(): RecipeService {
logger.v { "getRecipeService() called" }
return recipeServiceFactory.provideService()
}
}
@@ -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
}
@@ -1,14 +0,0 @@
package gq.kirmanak.mealient.data.recipes.network.response
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeIngredientResponse(
@SerialName("title") val title: String = "",
@SerialName("note") val note: String = "",
@SerialName("unit") val unit: String = "",
@SerialName("food") val food: String = "",
@SerialName("disableAmount") val disableAmount: Boolean,
@SerialName("quantity") val quantity: Int,
)
@@ -1,10 +0,0 @@
package gq.kirmanak.mealient.data.recipes.network.response
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeInstructionResponse(
@SerialName("title") val title: String = "",
@SerialName("text") val text: String,
)
@@ -1,23 +0,0 @@
package gq.kirmanak.mealient.data.recipes.network.response
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeResponse(
@SerialName("id") val remoteId: Long,
@SerialName("name") val name: String,
@SerialName("slug") val slug: String,
@SerialName("image") val image: String,
@SerialName("description") val description: String = "",
@SerialName("recipeCategory") val recipeCategories: List<String>,
@SerialName("tags") val tags: List<String>,
@SerialName("rating") val rating: Int?,
@SerialName("dateAdded") val dateAdded: LocalDate,
@SerialName("dateUpdated") val dateUpdated: LocalDateTime,
@SerialName("recipeYield") val recipeYield: String = "",
@SerialName("recipeIngredient") val recipeIngredients: List<GetRecipeIngredientResponse>,
@SerialName("recipeInstructions") val recipeInstructions: List<GetRecipeInstructionResponse>,
)
@@ -1,24 +0,0 @@
package gq.kirmanak.mealient.data.recipes.network.response
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeSummaryResponse(
@SerialName("id") val remoteId: Long,
@SerialName("name") val name: String,
@SerialName("slug") val slug: String,
@SerialName("image") val image: String?,
@SerialName("description") val description: String = "",
@SerialName("recipeCategory") val recipeCategories: List<String>,
@SerialName("tags") val tags: List<String>,
@SerialName("rating") val rating: Int?,
@SerialName("dateAdded") val dateAdded: LocalDate,
@SerialName("dateUpdated") val dateUpdated: LocalDateTime
) {
override fun toString(): String {
return "GetRecipeSummaryResponse(remoteId=$remoteId, name='$name')"
}
}
@@ -2,46 +2,20 @@ package gq.kirmanak.mealient.di
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.add.AddRecipeDataSource
import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.data.add.impl.AddRecipeDataSourceImpl
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.AddRecipeStorageImpl
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
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
@Singleton
@@ -13,16 +13,7 @@ import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl
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.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
@Module
@@ -31,20 +22,6 @@ interface AuthModule {
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
@Singleton
fun provideAccountManager(@ApplicationContext context: Context): AccountManager {
@@ -2,44 +2,18 @@ package gq.kirmanak.mealient.di
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.impl.BaseURLStorageImpl
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
@Module
@InstallIn(SingletonComponent::class)
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
@Singleton
fun bindVersionDataSource(versionDataSourceImpl: VersionDataSourceImpl): VersionDataSource
@@ -8,7 +8,6 @@ import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger
import okhttp3.OkHttpClient
import java.io.InputStream
import javax.inject.Named
@EntryPoint
@InstallIn(SingletonComponent::class)
@@ -16,7 +15,6 @@ interface GlideModuleEntryPoint {
fun provideLogger(): Logger
@Named(AUTH_OK_HTTP)
fun provideOkHttp(): OkHttpClient
fun provideRecipeLoaderFactory(): ModelLoaderFactory<RecipeSummaryEntity, InputStream>
@@ -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
}
}
@@ -9,10 +9,6 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
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.db.RecipeStorage
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.network.RecipeDataSource
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.logging.Logger
import gq.kirmanak.mealient.ui.recipes.images.RecipeModelLoaderFactory
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import java.io.InputStream
import javax.inject.Named
import javax.inject.Singleton
@Module
@@ -57,20 +48,6 @@ interface RecipeModule {
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
@Singleton
fun provideRecipePagingSourceFactory(
@@ -1,25 +1,7 @@
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 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(
block: () -> T,
@@ -1,15 +1,12 @@
package gq.kirmanak.mealient.extensions
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.RecipeIngredientEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity
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(
remoteId = remoteId,
@@ -45,4 +42,26 @@ fun GetRecipeSummaryResponse.recipeEntity() = RecipeSummaryEntity(
dateUpdated = dateUpdated,
)
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,
)
@@ -12,12 +12,12 @@ import androidx.fragment.app.viewModels
import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint
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.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.collectWhenViewResumed
import gq.kirmanak.mealient.logging.Logger
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
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.logging.Logger
import kotlinx.coroutines.channels.Channel
@@ -9,8 +9,8 @@ import androidx.navigation.fragment.findNavController
import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
import gq.kirmanak.mealient.datasource.models.NetworkError
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState
@@ -9,8 +9,8 @@ import androidx.navigation.fragment.findNavController
import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding
import gq.kirmanak.mealient.datasource.models.NetworkError
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState
@@ -1,12 +1,12 @@
package gq.kirmanak.mealient.ui.recipes.images
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.ModelCache
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.*
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.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.datasource.DataSourceModule.Companion.AUTHORIZATION_HEADER_NAME
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.runBlocking
import java.io.InputStream
@@ -16,6 +16,7 @@ import javax.inject.Singleton
class RecipeModelLoader private constructor(
private val recipeImageUrlProvider: RecipeImageUrlProvider,
private val logger: Logger,
private val authRepo: AuthRepo,
concreteLoader: ModelLoader<GlideUrl, InputStream>,
cache: ModelCache<RecipeSummaryEntity, GlideUrl>,
) : BaseGlideUrlLoader<RecipeSummaryEntity>(concreteLoader, cache) {
@@ -24,12 +25,13 @@ class RecipeModelLoader private constructor(
class Factory @Inject constructor(
private val recipeImageUrlProvider: RecipeImageUrlProvider,
private val logger: Logger,
private val authRepo: AuthRepo,
) {
fun build(
concreteLoader: ModelLoader<GlideUrl, InputStream>,
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" }
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,20 +0,0 @@
package gq.kirmanak.mealient.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.Interceptor
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ReleaseModule {
// Release version of the application doesn't have any interceptors but this Set
// is required by Dagger, so an empty Set is provided here
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
@Provides
@Singleton
fun provideInterceptors(): Set<@JvmSuppressWildcards Interceptor> = emptySet()
}