Major code refactoring

Main goals are:
1. Ability to use mocks in unit tests instead of
having to setup mock web server as if it was an
integration test.
2. Cache Retrofit services in memory
3. Make it easier to read
4. Use OptIn where possible instead of propagating
Experimental* annotations everywhere
This commit is contained in:
Kirill Kamakin
2022-04-02 19:04:44 +05:00
parent 405d983a90
commit 7fc2887dc7
40 changed files with 533 additions and 676 deletions

View File

@@ -7,7 +7,11 @@ interface AuthRepo {
suspend fun getBaseUrl(): String?
suspend fun getToken(): String?
suspend fun requireBaseUrl(): String
suspend fun getAuthHeader(): String?
suspend fun requireAuthHeader(): String
fun authenticationStatuses(): Flow<Boolean>

View File

@@ -3,13 +3,13 @@ package gq.kirmanak.mealient.data.auth
import kotlinx.coroutines.flow.Flow
interface AuthStorage {
fun storeAuthData(token: String, baseUrl: String)
fun storeAuthData(authHeader: String, baseUrl: String)
suspend fun getBaseUrl(): String?
suspend fun getToken(): String?
suspend fun getAuthHeader(): String?
fun tokenObservable(): Flow<String?>
fun authHeaderObservable(): Flow<String?>
fun clearAuthData()
}

View File

@@ -3,20 +3,18 @@ package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.*
import gq.kirmanak.mealient.data.impl.ErrorDetail
import gq.kirmanak.mealient.data.impl.RetrofitBuilder
import gq.kirmanak.mealient.data.impl.util.decodeErrorBodyOrNull
import gq.kirmanak.mealient.data.network.ServiceFactory
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import retrofit2.create
import retrofit2.Response
import timber.log.Timber
import javax.inject.Inject
@ExperimentalSerializationApi
class AuthDataSourceImpl @Inject constructor(
private val retrofitBuilder: RetrofitBuilder,
private val authServiceFactory: ServiceFactory<AuthService>,
private val json: Json,
) : AuthDataSource {
@@ -26,31 +24,37 @@ class AuthDataSourceImpl @Inject constructor(
baseUrl: String
): String {
Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl")
val authService = retrofitBuilder.buildRetrofit(baseUrl).create<AuthService>()
val accessToken = runCatching {
val response = authService.getToken(username, password)
Timber.d("authenticate() response is $response")
if (response.isSuccessful) {
checkNotNull(response.body()).accessToken
} else {
val cause = HttpException(response)
val errorDetail: ErrorDetail? = response.decodeErrorBodyOrNull(json)
throw when (errorDetail?.detail) {
"Unauthorized" -> Unauthorized(cause)
else -> NotMealie(cause)
}
}
}.onFailure {
Timber.e(it, "authenticate: getToken failed")
throw when (it) {
is CancellationException, is AuthenticationError -> it
is SerializationException, is IllegalStateException -> NotMealie(it)
else -> NoServerConnection(it)
}
}.getOrThrow()
val authService = authServiceFactory.provideService(baseUrl)
val response = sendRequest(authService, username, password)
val accessToken = parseToken(response)
Timber.v("authenticate() returned: $accessToken")
return accessToken
}
private suspend fun sendRequest(
authService: AuthService,
username: String,
password: String
): Response<GetTokenResponse> = try {
authService.getToken(username, password)
} catch (e: Throwable) {
throw when (e) {
is CancellationException -> e
is SerializationException -> NotMealie(e)
else -> NoServerConnection(e)
}
}
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: ErrorDetail? = response.decodeErrorBodyOrNull(json)
throw when (errorDetail?.detail) {
"Unauthorized" -> Unauthorized(cause)
else -> NotMealie(cause)
}
}
}

View File

@@ -1,29 +0,0 @@
package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.data.auth.AuthStorage
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import timber.log.Timber
import javax.inject.Inject
const val AUTHORIZATION_HEADER = "Authorization"
class AuthOkHttpInterceptor @Inject constructor(
private val authStorage: AuthStorage
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
Timber.v("intercept() called with: chain = $chain")
val token = runBlocking { authStorage.getToken() }
Timber.d("intercept: token = $token")
val request = if (token.isNullOrBlank()) {
chain.request()
} else {
chain.request()
.newBuilder()
.addHeader(AUTHORIZATION_HEADER, "Bearer $token")
.build()
}
return chain.proceed(request)
}
}

View File

@@ -14,8 +14,9 @@ import javax.inject.Inject
class AuthRepoImpl @Inject constructor(
private val dataSource: AuthDataSource,
private val storage: AuthStorage
private val storage: AuthStorage,
) : AuthRepo {
override suspend fun authenticate(
username: String,
password: String,
@@ -24,20 +25,23 @@ class AuthRepoImpl @Inject constructor(
Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl")
val url = parseBaseUrl(baseUrl)
val accessToken = dataSource.authenticate(username, password, url)
Timber.d("authenticate result is $accessToken")
storage.storeAuthData(accessToken, url)
Timber.d("authenticate result is \"$accessToken\"")
storage.storeAuthData(AUTH_HEADER_FORMAT.format(accessToken), url)
}
override suspend fun getBaseUrl(): String? = storage.getBaseUrl()
override suspend fun getToken(): String? {
Timber.v("getToken() called")
return storage.getToken()
}
override suspend fun requireBaseUrl(): String =
checkNotNull(getBaseUrl()) { "Base URL is null when it was required" }
override suspend fun getAuthHeader(): String? = storage.getAuthHeader()
override suspend fun requireAuthHeader(): String =
checkNotNull(getAuthHeader()) { "Auth header is null when it was required" }
override fun authenticationStatuses(): Flow<Boolean> {
Timber.v("authenticationStatuses() called")
return storage.tokenObservable().map { it != null }
return storage.authHeaderObservable().map { it != null }
}
override fun logout() {
@@ -57,4 +61,7 @@ class AuthRepoImpl @Inject constructor(
throw MalformedUrl(e)
}
companion object {
private const val AUTH_HEADER_FORMAT = "Bearer %s"
}
}

View File

@@ -11,9 +11,5 @@ interface AuthService {
suspend fun getToken(
@Field("username") username: String,
@Field("password") password: String,
@Field("grant_type") grantType: String? = null,
@Field("scope") scope: String? = null,
@Field("client_id") clientId: String? = null,
@Field("client_secret") clientSecret: String? = null
): Response<GetTokenResponse>
}

View File

@@ -1,29 +1,28 @@
package gq.kirmanak.mealient.data.auth.impl
import android.content.SharedPreferences
import androidx.core.content.edit
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.impl.util.changesFlow
import gq.kirmanak.mealient.data.impl.util.getStringOrNull
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber
import javax.inject.Inject
private const val TOKEN_KEY = "AUTH_TOKEN"
private const val AUTH_HEADER_KEY = "AUTH_TOKEN"
private const val BASE_URL_KEY = "BASE_URL"
@ExperimentalCoroutinesApi
class AuthStorageImpl @Inject constructor(
private val sharedPreferences: SharedPreferences
) : AuthStorage {
override fun storeAuthData(token: String, baseUrl: String) {
Timber.v("storeAuthData() called with: token = $token, baseUrl = $baseUrl")
sharedPreferences.edit()
.putString(TOKEN_KEY, token)
.putString(BASE_URL_KEY, baseUrl)
.apply()
override fun storeAuthData(authHeader: String, baseUrl: String) {
Timber.v("storeAuthData() called with: authHeader = $authHeader, baseUrl = $baseUrl")
sharedPreferences.edit {
putString(AUTH_HEADER_KEY, authHeader)
putString(BASE_URL_KEY, baseUrl)
}
}
override suspend fun getBaseUrl(): String? {
@@ -32,23 +31,23 @@ class AuthStorageImpl @Inject constructor(
return baseUrl
}
override suspend fun getToken(): String? {
Timber.v("getToken() called")
val token = sharedPreferences.getStringOrNull(TOKEN_KEY)
Timber.d("getToken: token is $token")
override suspend fun getAuthHeader(): String? {
Timber.v("getAuthHeader() called")
val token = sharedPreferences.getStringOrNull(AUTH_HEADER_KEY)
Timber.d("getAuthHeader: header is \"$token\"")
return token
}
override fun tokenObservable(): Flow<String?> {
Timber.v("tokenObservable() called")
return sharedPreferences.changesFlow().map { it.first.getStringOrNull(TOKEN_KEY) }
override fun authHeaderObservable(): Flow<String?> {
Timber.v("authHeaderObservable() called")
return sharedPreferences.changesFlow().map { it.first.getStringOrNull(AUTH_HEADER_KEY) }
}
override fun clearAuthData() {
Timber.v("clearAuthData() called")
sharedPreferences.edit()
.remove(TOKEN_KEY)
.remove(BASE_URL_KEY)
.apply()
sharedPreferences.edit {
remove(AUTH_HEADER_KEY)
remove(BASE_URL_KEY)
}
}
}
}

View File

@@ -4,7 +4,4 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetTokenResponse(
@SerialName("access_token") val accessToken: String,
@SerialName("token_type") val tokenType: String
)
data class GetTokenResponse(@SerialName("access_token") val accessToken: String)

View File

@@ -9,11 +9,12 @@ import retrofit2.Retrofit
import timber.log.Timber
import javax.inject.Inject
@ExperimentalSerializationApi
class RetrofitBuilder @Inject constructor(
private val okHttpClient: OkHttpClient,
private val json: Json
) {
@OptIn(ExperimentalSerializationApi::class)
fun buildRetrofit(baseUrl: String): Retrofit {
Timber.v("buildRetrofit() called with: baseUrl = $baseUrl")
val contentType = "application/json".toMediaType()

View File

@@ -7,11 +7,10 @@ import retrofit2.Response
import timber.log.Timber
import java.io.InputStream
@ExperimentalSerializationApi
inline fun <T, reified R> Response<T>.decodeErrorBodyOrNull(json: Json): R? =
errorBody()?.byteStream()?.let { json.decodeFromStreamOrNull<R>(it) }
@ExperimentalSerializationApi
@OptIn(ExperimentalSerializationApi::class)
inline fun <reified T> Json.decodeFromStreamOrNull(stream: InputStream): T? =
runCatching { decodeFromStream<T>(stream) }
.onFailure { Timber.e(it, "decodeFromStreamOrNull: can't decode") }

View File

@@ -17,7 +17,7 @@ suspend fun SharedPreferences.getStringOrNull(key: String) =
suspend fun SharedPreferences.getBooleanOrFalse(key: String) =
withContext(Dispatchers.IO) { getBoolean(key, false) }
@ExperimentalCoroutinesApi
@OptIn(ExperimentalCoroutinesApi::class)
fun SharedPreferences.changesFlow(): Flow<Pair<SharedPreferences, String?>> = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, key ->
Timber.v("watchChanges: listener called with key $key")

View File

@@ -0,0 +1,29 @@
package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.impl.RetrofitBuilder
import timber.log.Timber
inline fun <reified T> RetrofitBuilder.createServiceFactory() =
RetrofitServiceFactory(T::class.java, this)
class RetrofitServiceFactory<T>(
private val serviceClass: Class<T>,
private val retrofitBuilder: RetrofitBuilder,
) : ServiceFactory<T> {
private val cache: MutableMap<String, T> = mutableMapOf()
@Synchronized
override fun provideService(baseUrl: String): T {
Timber.v("provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}")
val cached = cache[baseUrl]
return if (cached == null) {
Timber.d("provideService: cache is empty, creating new")
val new = retrofitBuilder.buildRetrofit(baseUrl).create(serviceClass)
cache[baseUrl] = new
new
} else {
cached
}
}
}

View File

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

View File

@@ -12,7 +12,7 @@ import kotlinx.coroutines.CancellationException
import timber.log.Timber
import javax.inject.Inject
@ExperimentalPagingApi
@OptIn(ExperimentalPagingApi::class)
class RecipeRepoImpl @Inject constructor(
private val mediator: RecipesRemoteMediator,
private val storage: RecipeStorage,

View File

@@ -11,7 +11,7 @@ import kotlinx.coroutines.CancellationException
import timber.log.Timber
import javax.inject.Inject
@ExperimentalPagingApi
@OptIn(ExperimentalPagingApi::class)
class RecipesRemoteMediator @Inject constructor(
private val storage: RecipeStorage,
private val network: RecipeDataSource,

View File

@@ -1,50 +1,35 @@
package gq.kirmanak.mealient.data.recipes.network
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.impl.RetrofitBuilder
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 kotlinx.serialization.ExperimentalSerializationApi
import timber.log.Timber
import javax.inject.Inject
@ExperimentalSerializationApi
class RecipeDataSourceImpl @Inject constructor(
private val authRepo: AuthRepo,
private val retrofitBuilder: RetrofitBuilder
private val recipeServiceFactory: ServiceFactory<RecipeService>,
) : RecipeDataSource {
private var _recipeService: RecipeService? = null
override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> {
Timber.v("requestRecipes() called with: start = $start, limit = $limit")
val service: RecipeService = getRecipeService()
val recipeSummary = service.getRecipeSummary(start, limit)
val recipeSummary = getRecipeService().getRecipeSummary(start, limit, getToken())
Timber.v("requestRecipes() returned: $recipeSummary")
return recipeSummary
}
override suspend fun requestRecipeInfo(slug: String): GetRecipeResponse {
Timber.v("requestRecipeInfo() called with: slug = $slug")
val service: RecipeService = getRecipeService()
val recipeInfo = service.getRecipe(slug)
val recipeInfo = getRecipeService().getRecipe(slug, getToken())
Timber.v("requestRecipeInfo() returned: $recipeInfo")
return recipeInfo
}
private suspend fun getRecipeService(): RecipeService {
Timber.v("getRecipeService() called")
val cachedService: RecipeService? = _recipeService
val service: RecipeService = if (cachedService == null) {
val baseUrl = checkNotNull(authRepo.getBaseUrl()) { "Base url is null" }
val token = checkNotNull(authRepo.getToken()) { "Token is null" }
Timber.d("requestRecipes: baseUrl = $baseUrl, token = $token")
val retrofit = retrofitBuilder.buildRetrofit(baseUrl)
val createdService = retrofit.create(RecipeService::class.java)
_recipeService = createdService
createdService
} else {
cachedService
}
return service
return recipeServiceFactory.provideService(authRepo.requireBaseUrl())
}
}
private suspend fun getToken(): String = authRepo.requireAuthHeader()
}

View File

@@ -3,6 +3,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 retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Path
import retrofit2.http.Query
@@ -10,11 +11,13 @@ interface RecipeService {
@GET("/api/recipes/summary")
suspend fun getRecipeSummary(
@Query("start") start: Int,
@Query("limit") limit: Int
@Query("limit") limit: Int,
@Header("Authorization") authHeader: String?,
): List<GetRecipeSummaryResponse>
@GET("/api/recipes/{recipe_slug}")
suspend fun getRecipe(
@Path("recipe_slug") recipeSlug: String
@Path("recipe_slug") recipeSlug: String,
@Header("Authorization") authHeader: String?,
): GetRecipeResponse
}

View File

@@ -2,25 +2,34 @@ package gq.kirmanak.mealient.di
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.data.auth.AuthDataSource
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.AuthOkHttpInterceptor
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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.serialization.ExperimentalSerializationApi
import okhttp3.Interceptor
import gq.kirmanak.mealient.data.impl.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
import javax.inject.Singleton
@ExperimentalCoroutinesApi
@ExperimentalSerializationApi
@Module
@InstallIn(SingletonComponent::class)
interface AuthModule {
companion object {
@Provides
@Singleton
fun provideAuthServiceFactory(retrofitBuilder: RetrofitBuilder): ServiceFactory<AuthService> {
return retrofitBuilder.createServiceFactory()
}
}
@Binds
fun bindAuthDataSource(authDataSourceImpl: AuthDataSourceImpl): AuthDataSource
@@ -29,8 +38,4 @@ interface AuthModule {
@Binds
fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo
@Binds
@IntoSet
fun bindAuthInterceptor(authOkHttpInterceptor: AuthOkHttpInterceptor): Interceptor
}

View File

@@ -1,12 +1,14 @@
package gq.kirmanak.mealient.di
import androidx.paging.ExperimentalPagingApi
import androidx.paging.InvalidatingPagingSourceFactory
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.impl.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
import gq.kirmanak.mealient.data.recipes.RecipeImageLoader
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
@@ -15,11 +17,9 @@ import gq.kirmanak.mealient.data.recipes.impl.RecipeImageLoaderImpl
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 kotlinx.serialization.ExperimentalSerializationApi
import gq.kirmanak.mealient.data.recipes.network.RecipeService
import javax.inject.Singleton
@ExperimentalPagingApi
@ExperimentalSerializationApi
@Module
@InstallIn(SingletonComponent::class)
interface RecipeModule {
@@ -36,6 +36,13 @@ interface RecipeModule {
fun provideRecipeImageLoader(recipeImageLoaderImpl: RecipeImageLoaderImpl): RecipeImageLoader
companion object {
@Provides
@Singleton
fun provideRecipeServiceFactory(retrofitBuilder: RetrofitBuilder): ServiceFactory<RecipeService> {
return retrofitBuilder.createServiceFactory()
}
@Provides
@Singleton
fun provideRecipePagingSourceFactory(

View File

@@ -20,7 +20,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import timber.log.Timber
@ExperimentalCoroutinesApi
@OptIn(ExperimentalCoroutinesApi::class)
fun SwipeRefreshLayout.refreshesLiveData(): LiveData<Unit> {
val callbackFlow: Flow<Unit> = callbackFlow {
val listener = SwipeRefreshLayout.OnRefreshListener {
@@ -63,7 +63,7 @@ fun AppCompatActivity.setActionBarVisibility(isVisible: Boolean) {
?: Timber.w("setActionBarVisibility: action bar is null")
}
@ExperimentalCoroutinesApi
@OptIn(ExperimentalCoroutinesApi::class)
fun TextView.textChangesFlow(): Flow<CharSequence?> = callbackFlow {
Timber.v("textChangesFlow() called")
val textWatcher = doAfterTextChanged {

View File

@@ -16,12 +16,10 @@ import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.*
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
import gq.kirmanak.mealient.ui.textChangesFlow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import timber.log.Timber
@ExperimentalCoroutinesApi
@AndroidEntryPoint
class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
private val binding by viewBinding(FragmentAuthenticationBinding::bind)

View File

@@ -15,11 +15,9 @@ import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.FragmentRecipesBinding
import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel
import gq.kirmanak.mealient.ui.refreshesLiveData
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import timber.log.Timber
@ExperimentalCoroutinesApi
@AndroidEntryPoint
class RecipesFragment : Fragment(R.layout.fragment_recipes) {
private val binding by viewBinding(FragmentRecipesBinding::bind)