Merge pull request #29 from kirmanak/data-store

Migrate from Shared Preferences to Data Store
This commit is contained in:
Kirill Kamakin
2022-04-03 20:07:24 +05:00
committed by GitHub
40 changed files with 331 additions and 187 deletions

View File

@@ -120,9 +120,6 @@ dependencies {
// https://github.com/Kotlin/kotlinx.serialization/releases // https://github.com/Kotlin/kotlinx.serialization/releases
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2"
// https://developer.android.com/jetpack/androidx/releases/preference
implementation "androidx.preference:preference-ktx:1.2.0"
// https://github.com/JakeWharton/timber/releases // https://github.com/JakeWharton/timber/releases
implementation 'com.jakewharton.timber:timber:5.0.1' implementation 'com.jakewharton.timber:timber:5.0.1'
@@ -145,6 +142,12 @@ dependencies {
// https://github.com/square/picasso/releases // https://github.com/square/picasso/releases
implementation "com.squareup.picasso:picasso:2.8" implementation "com.squareup.picasso:picasso:2.8"
// https://github.com/androidbroadcast/ViewBindingPropertyDelegate/releases
implementation "com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.6"
// https://developer.android.com/topic/libraries/architecture/datastore
implementation "androidx.datastore:datastore-preferences:1.0.0"
// https://github.com/junit-team/junit4/releases // https://github.com/junit-team/junit4/releases
testImplementation "junit:junit:4.13.2" testImplementation "junit:junit:4.13.2"
@@ -163,9 +166,6 @@ dependencies {
// https://mockk.io/ // https://mockk.io/
testImplementation "io.mockk:mockk:1.12.3" testImplementation "io.mockk:mockk:1.12.3"
// https://github.com/androidbroadcast/ViewBindingPropertyDelegate/releases
implementation "com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.6"
// https://github.com/facebook/flipper/releases // https://github.com/facebook/flipper/releases
def flipper_version = "0.140.0" def flipper_version = "0.140.0"
debugImplementation "com.facebook.flipper:flipper:$flipper_version" debugImplementation "com.facebook.flipper:flipper:$flipper_version"

View File

@@ -25,54 +25,54 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object DebugModule { object DebugModule {
@Provides @Provides
@Singleton @Singleton
@IntoSet @IntoSet
fun provideLoggingInterceptor(): Interceptor { fun provideLoggingInterceptor(): Interceptor {
val interceptor = HttpLoggingInterceptor { message -> Timber.tag("OkHttp").v(message) } val interceptor = HttpLoggingInterceptor { message -> Timber.tag("OkHttp").v(message) }
interceptor.level = HttpLoggingInterceptor.Level.BODY interceptor.level = HttpLoggingInterceptor.Level.BODY
return interceptor return interceptor
} }
@Provides @Provides
@Singleton @Singleton
@IntoSet @IntoSet
fun provideFlipperInterceptor(networkFlipperPlugin: NetworkFlipperPlugin): Interceptor { fun provideFlipperInterceptor(networkFlipperPlugin: NetworkFlipperPlugin): Interceptor {
return FlipperOkhttpInterceptor(networkFlipperPlugin) return FlipperOkhttpInterceptor(networkFlipperPlugin)
} }
@Provides @Provides
@Singleton @Singleton
fun networkFlipperPlugin() = NetworkFlipperPlugin() fun networkFlipperPlugin() = NetworkFlipperPlugin()
@Provides @Provides
@Singleton @Singleton
@IntoSet @IntoSet
fun bindNetworkFlipperPlugin(plugin: NetworkFlipperPlugin): FlipperPlugin = plugin fun bindNetworkFlipperPlugin(plugin: NetworkFlipperPlugin): FlipperPlugin = plugin
@Provides @Provides
@Singleton @Singleton
@IntoSet @IntoSet
fun sharedPreferencesPlugin(@ApplicationContext context: Context): FlipperPlugin = fun sharedPreferencesPlugin(@ApplicationContext context: Context): FlipperPlugin =
SharedPreferencesFlipperPlugin(context) SharedPreferencesFlipperPlugin(context)
@Provides @Provides
@Singleton @Singleton
@IntoSet @IntoSet
fun leakCanaryPlugin(): FlipperPlugin { fun leakCanaryPlugin(): FlipperPlugin {
LeakCanary.config = LeakCanary.config.copy(onHeapAnalyzedListener = FlipperLeakListener()) LeakCanary.config = LeakCanary.config.copy(onHeapAnalyzedListener = FlipperLeakListener())
return LeakCanary2FlipperPlugin() return LeakCanary2FlipperPlugin()
} }
@Provides @Provides
@Singleton @Singleton
@IntoSet @IntoSet
fun databasesPlugin(@ApplicationContext context: Context): FlipperPlugin = fun databasesPlugin(@ApplicationContext context: Context): FlipperPlugin =
DatabasesFlipperPlugin(context) DatabasesFlipperPlugin(context)
@Provides @Provides
@Singleton @Singleton
@IntoSet @IntoSet
fun inspectorPlugin(@ApplicationContext context: Context): FlipperPlugin = fun inspectorPlugin(@ApplicationContext context: Context): FlipperPlugin =
InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()) InspectorFlipperPlugin(context, DescriptorMapping.withDefaults())
} }

View File

@@ -3,9 +3,9 @@ package gq.kirmanak.mealient.data
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import gq.kirmanak.mealient.data.impl.util.RoomTypeConverters
import gq.kirmanak.mealient.data.recipes.db.RecipeDao import gq.kirmanak.mealient.data.recipes.db.RecipeDao
import gq.kirmanak.mealient.data.recipes.db.entity.* import gq.kirmanak.mealient.data.recipes.db.entity.*
import gq.kirmanak.mealient.extensions.RoomTypeConverters
@Database( @Database(
version = 1, version = 1,

View File

@@ -15,5 +15,5 @@ interface AuthRepo {
fun authenticationStatuses(): Flow<Boolean> fun authenticationStatuses(): Flow<Boolean>
fun logout() suspend fun logout()
} }

View File

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

View File

@@ -2,9 +2,9 @@ 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.impl.AuthenticationError.* import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.*
import gq.kirmanak.mealient.data.impl.ErrorDetail import gq.kirmanak.mealient.data.network.ErrorDetail
import gq.kirmanak.mealient.data.impl.util.decodeErrorBodyOrNull
import gq.kirmanak.mealient.data.network.ServiceFactory import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.extensions.decodeErrorBodyOrNull
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -12,7 +12,9 @@ import retrofit2.HttpException
import retrofit2.Response import retrofit2.Response
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthDataSourceImpl @Inject constructor( class AuthDataSourceImpl @Inject constructor(
private val authServiceFactory: ServiceFactory<AuthService>, private val authServiceFactory: ServiceFactory<AuthService>,
private val json: Json, private val json: Json,

View File

@@ -11,7 +11,9 @@ import kotlinx.coroutines.flow.map
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthRepoImpl @Inject constructor( class AuthRepoImpl @Inject constructor(
private val dataSource: AuthDataSource, private val dataSource: AuthDataSource,
private val storage: AuthStorage, private val storage: AuthStorage,
@@ -44,7 +46,7 @@ class AuthRepoImpl @Inject constructor(
return storage.authHeaderObservable().map { it != null } return storage.authHeaderObservable().map { it != null }
} }
override fun logout() { override suspend fun logout() {
Timber.v("logout() called") Timber.v("logout() called")
storage.clearAuthData() storage.clearAuthData()
} }

View File

@@ -1,53 +1,48 @@
package gq.kirmanak.mealient.data.auth.impl 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.auth.AuthStorage
import gq.kirmanak.mealient.data.impl.util.changesFlow import gq.kirmanak.mealient.data.storage.PreferencesStorage
import gq.kirmanak.mealient.data.impl.util.getStringOrNull
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
private const val AUTH_HEADER_KEY = "AUTH_TOKEN" @Singleton
private const val BASE_URL_KEY = "BASE_URL"
class AuthStorageImpl @Inject constructor( class AuthStorageImpl @Inject constructor(
private val sharedPreferences: SharedPreferences private val preferencesStorage: PreferencesStorage,
) : AuthStorage { ) : AuthStorage {
override fun storeAuthData(authHeader: String, baseUrl: String) { private val authHeaderKey by preferencesStorage::authHeaderKey
private val baseUrlKey by preferencesStorage::baseUrlKey
override suspend fun storeAuthData(authHeader: String, baseUrl: String) {
Timber.v("storeAuthData() called with: authHeader = $authHeader, baseUrl = $baseUrl") Timber.v("storeAuthData() called with: authHeader = $authHeader, baseUrl = $baseUrl")
sharedPreferences.edit { preferencesStorage.storeValues(
putString(AUTH_HEADER_KEY, authHeader) Pair(authHeaderKey, authHeader),
putString(BASE_URL_KEY, baseUrl) Pair(baseUrlKey, baseUrl),
} )
} }
override suspend fun getBaseUrl(): String? { override suspend fun getBaseUrl(): String? {
val baseUrl = sharedPreferences.getStringOrNull(BASE_URL_KEY) val baseUrl = preferencesStorage.getValue(baseUrlKey)
Timber.d("getBaseUrl: base url is $baseUrl") Timber.d("getBaseUrl: base url is $baseUrl")
return baseUrl return baseUrl
} }
override suspend fun getAuthHeader(): String? { override suspend fun getAuthHeader(): String? {
Timber.v("getAuthHeader() called") Timber.v("getAuthHeader() called")
val token = sharedPreferences.getStringOrNull(AUTH_HEADER_KEY) val token = preferencesStorage.getValue(authHeaderKey)
Timber.d("getAuthHeader: header is \"$token\"") Timber.d("getAuthHeader: header is \"$token\"")
return token return token
} }
override fun authHeaderObservable(): Flow<String?> { override fun authHeaderObservable(): Flow<String?> {
Timber.v("authHeaderObservable() called") Timber.v("authHeaderObservable() called")
return sharedPreferences.changesFlow().map { it.first.getStringOrNull(AUTH_HEADER_KEY) } return preferencesStorage.valueUpdates(authHeaderKey)
} }
override fun clearAuthData() { override suspend fun clearAuthData() {
Timber.v("clearAuthData() called") Timber.v("clearAuthData() called")
sharedPreferences.edit { preferencesStorage.removeValues(authHeaderKey, baseUrlKey)
remove(AUTH_HEADER_KEY)
remove(BASE_URL_KEY)
}
} }
} }

View File

@@ -3,5 +3,5 @@ package gq.kirmanak.mealient.data.disclaimer
interface DisclaimerStorage { interface DisclaimerStorage {
suspend fun isDisclaimerAccepted(): Boolean suspend fun isDisclaimerAccepted(): Boolean
fun acceptDisclaimer() suspend fun acceptDisclaimer()
} }

View File

@@ -1,27 +1,26 @@
package gq.kirmanak.mealient.data.disclaimer package gq.kirmanak.mealient.data.disclaimer
import android.content.SharedPreferences import gq.kirmanak.mealient.data.storage.PreferencesStorage
import gq.kirmanak.mealient.data.impl.util.getBooleanOrFalse
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
private const val IS_DISCLAIMER_ACCEPTED_KEY = "IS_DISCLAIMER_ACCEPTED" @Singleton
class DisclaimerStorageImpl @Inject constructor( class DisclaimerStorageImpl @Inject constructor(
private val sharedPreferences: SharedPreferences private val preferencesStorage: PreferencesStorage,
) : DisclaimerStorage { ) : DisclaimerStorage {
private val isDisclaimerAcceptedKey by preferencesStorage::isDisclaimerAcceptedKey
override suspend fun isDisclaimerAccepted(): Boolean { override suspend fun isDisclaimerAccepted(): Boolean {
Timber.v("isDisclaimerAccepted() called") Timber.v("isDisclaimerAccepted() called")
val isAccepted = sharedPreferences.getBooleanOrFalse(IS_DISCLAIMER_ACCEPTED_KEY) val isAccepted = preferencesStorage.getValue(isDisclaimerAcceptedKey) ?: false
Timber.v("isDisclaimerAccepted() returned: $isAccepted") Timber.v("isDisclaimerAccepted() returned: $isAccepted")
return isAccepted return isAccepted
} }
override fun acceptDisclaimer() { override suspend fun acceptDisclaimer() {
Timber.v("acceptDisclaimer() called") Timber.v("acceptDisclaimer() called")
sharedPreferences.edit() preferencesStorage.storeValues(Pair(isDisclaimerAcceptedKey, true))
.putBoolean(IS_DISCLAIMER_ACCEPTED_KEY, true)
.apply()
} }
} }

View File

@@ -1,36 +0,0 @@
package gq.kirmanak.mealient.data.impl.util
import android.content.SharedPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.onClosed
import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext
import timber.log.Timber
suspend fun SharedPreferences.getStringOrNull(key: String) =
withContext(Dispatchers.IO) { getString(key, null) }
suspend fun SharedPreferences.getBooleanOrFalse(key: String) =
withContext(Dispatchers.IO) { getBoolean(key, false) }
@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")
trySend(prefs to key)
.onFailure { Timber.e(it, "watchChanges: can't send preference change, key $key") }
.onClosed { Timber.e(it, "watchChanges: flow has been closed") }
}
Timber.v("watchChanges: registering listener")
registerOnSharedPreferenceChangeListener(listener)
send(this@changesFlow to null)
awaitClose {
Timber.v("watchChanges: flow has been closed")
unregisterOnSharedPreferenceChangeListener(listener)
}
}

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.impl package gq.kirmanak.mealient.data.network
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.impl package gq.kirmanak.mealient.data.network
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.impl package gq.kirmanak.mealient.data.network
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
@@ -8,7 +8,9 @@ import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RetrofitBuilder @Inject constructor( class RetrofitBuilder @Inject constructor(
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
private val json: Json private val json: Json

View File

@@ -1,6 +1,5 @@
package gq.kirmanak.mealient.data.network package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.impl.RetrofitBuilder
import timber.log.Timber import timber.log.Timber
inline fun <reified T> RetrofitBuilder.createServiceFactory() = inline fun <reified T> RetrofitBuilder.createServiceFactory() =

View File

@@ -3,17 +3,19 @@ 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.AppDb import gq.kirmanak.mealient.data.AppDb
import gq.kirmanak.mealient.data.impl.util.recipeEntity
import gq.kirmanak.mealient.data.impl.util.toRecipeEntity
import gq.kirmanak.mealient.data.impl.util.toRecipeIngredientEntity
import gq.kirmanak.mealient.data.impl.util.toRecipeInstructionEntity
import gq.kirmanak.mealient.data.recipes.db.entity.* import gq.kirmanak.mealient.data.recipes.db.entity.*
import gq.kirmanak.mealient.data.recipes.impl.FullRecipeInfo import gq.kirmanak.mealient.data.recipes.impl.FullRecipeInfo
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import gq.kirmanak.mealient.extensions.recipeEntity
import gq.kirmanak.mealient.extensions.toRecipeEntity
import gq.kirmanak.mealient.extensions.toRecipeIngredientEntity
import gq.kirmanak.mealient.extensions.toRecipeInstructionEntity
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecipeStorageImpl @Inject constructor( class RecipeStorageImpl @Inject constructor(
private val db: AppDb private val db: AppDb
) : RecipeStorage { ) : RecipeStorage {

View File

@@ -9,7 +9,9 @@ import gq.kirmanak.mealient.ui.ImageLoader
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecipeImageLoaderImpl @Inject constructor( class RecipeImageLoaderImpl @Inject constructor(
private val imageLoader: ImageLoader, private val imageLoader: ImageLoader,
private val authRepo: AuthRepo private val authRepo: AuthRepo

View File

@@ -11,8 +11,10 @@ import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
@Singleton
class RecipeRepoImpl @Inject constructor( class RecipeRepoImpl @Inject constructor(
private val mediator: RecipesRemoteMediator, private val mediator: RecipesRemoteMediator,
private val storage: RecipeStorage, private val storage: RecipeStorage,

View File

@@ -10,8 +10,10 @@ import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
@Singleton
class RecipesRemoteMediator @Inject constructor( class RecipesRemoteMediator @Inject constructor(
private val storage: RecipeStorage, private val storage: RecipeStorage,
private val network: RecipeDataSource, private val network: RecipeDataSource,

View File

@@ -6,7 +6,9 @@ import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecipeDataSourceImpl @Inject constructor( class RecipeDataSourceImpl @Inject constructor(
private val authRepo: AuthRepo, private val authRepo: AuthRepo,
private val recipeServiceFactory: ServiceFactory<RecipeService>, private val recipeServiceFactory: ServiceFactory<RecipeService>,

View File

@@ -0,0 +1,23 @@
package gq.kirmanak.mealient.data.storage
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.flow.Flow
interface PreferencesStorage {
val baseUrlKey: Preferences.Key<String>
val authHeaderKey: Preferences.Key<String>
val isDisclaimerAcceptedKey: Preferences.Key<Boolean>
suspend fun <T> getValue(key: Preferences.Key<T>): T?
suspend fun <T> requireValue(key: Preferences.Key<T>): T
suspend fun <T> storeValues(vararg pairs: Pair<Preferences.Key<T>, T>)
fun <T> valueUpdates(key: Preferences.Key<T>): Flow<T?>
suspend fun <T> removeValues(vararg keys: Preferences.Key<T>)
}

View File

@@ -0,0 +1,60 @@
package gq.kirmanak.mealient.data.storage
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.*
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PreferencesStorageImpl @Inject constructor(
private val dataStore: DataStore<Preferences>
) : PreferencesStorage {
override val baseUrlKey = stringPreferencesKey("baseUrl")
override val authHeaderKey = stringPreferencesKey("authHeader")
override val isDisclaimerAcceptedKey = booleanPreferencesKey("isDisclaimedAccepted")
override suspend fun <T> getValue(key: Preferences.Key<T>): T? {
val value = dataStore.data.first()[key]
Timber.v("getValue() returned: $value for $key")
return value
}
override suspend fun <T> requireValue(key: Preferences.Key<T>): T =
checkNotNull(getValue(key)) { "Value at $key is null when it was required" }
override suspend fun <T> storeValues(vararg pairs: Pair<Preferences.Key<T>, T>) {
Timber.v("storeValues() called with: pairs = ${pairs.contentToString()}")
dataStore.edit { preferences ->
pairs.forEach { preferences += it.toPreferencesPair() }
}
}
override fun <T> valueUpdates(key: Preferences.Key<T>): Flow<T?> {
Timber.v("valueUpdates() called with: key = $key")
return dataStore.data
.map { it[key] }
.distinctUntilChanged()
.onEach { Timber.d("valueUpdates: new value at $key is $it") }
.onCompletion { Timber.i(it, "valueUpdates: finished") }
}
override suspend fun <T> removeValues(vararg keys: Preferences.Key<T>) {
Timber.v("removeValues() called with: key = ${keys.contentToString()}")
dataStore.edit { preferences ->
keys.forEach { preferences -= it }
}
}
}
private fun <T> Pair<Preferences.Key<T>, T>.toPreferencesPair(): Preferences.Pair<T> {
val (key, value) = this
return key to value
}

View File

@@ -1,42 +1,39 @@
package gq.kirmanak.mealient.di package gq.kirmanak.mealient.di
import android.content.Context import android.content.Context
import android.content.SharedPreferences import androidx.datastore.core.DataStore
import androidx.preference.PreferenceManager import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.room.Room import androidx.room.Room
import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.AppDb import gq.kirmanak.mealient.data.AppDb
import gq.kirmanak.mealient.data.impl.OkHttpBuilder import gq.kirmanak.mealient.data.storage.PreferencesStorage
import kotlinx.serialization.json.Json import gq.kirmanak.mealient.data.storage.PreferencesStorageImpl
import okhttp3.OkHttpClient
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object AppModule { interface AppModule {
@Provides companion object {
@Singleton
fun createDb(@ApplicationContext context: Context): AppDb =
Room.databaseBuilder(context, AppDb::class.java, "app.db").build()
@Provides @Provides
@Singleton @Singleton
fun createSharedPreferences(@ApplicationContext context: Context): SharedPreferences = fun createDb(@ApplicationContext context: Context): AppDb =
PreferenceManager.getDefaultSharedPreferences(context) Room.databaseBuilder(context, AppDb::class.java, "app.db").build()
@Provides @Provides
@Singleton @Singleton
fun createOkHttp(okHttpBuilder: OkHttpBuilder): OkHttpClient = fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
okHttpBuilder.buildOkHttp() PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("settings") }
@Provides
@Singleton
fun createJson(): Json = Json {
coerceInputValues = true
ignoreUnknownKeys = true
} }
@Binds
@Singleton
fun bindPreferencesStorage(preferencesStorage: PreferencesStorageImpl): PreferencesStorage
} }

View File

@@ -1,12 +1,9 @@
package gq.kirmanak.mealient.di package gq.kirmanak.mealient.di
import android.accounts.AccountManager
import android.content.Context
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
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
@@ -15,7 +12,7 @@ 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.AuthService
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl
import gq.kirmanak.mealient.data.impl.RetrofitBuilder import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory import gq.kirmanak.mealient.data.network.createServiceFactory
import javax.inject.Singleton import javax.inject.Singleton

View File

@@ -0,0 +1,27 @@
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.OkHttpBuilder
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun createOkHttp(okHttpBuilder: OkHttpBuilder): OkHttpClient =
okHttpBuilder.buildOkHttp()
@Provides
@Singleton
fun createJson(): Json = Json {
coerceInputValues = true
ignoreUnknownKeys = true
}
}

View File

@@ -6,7 +6,7 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.impl.RetrofitBuilder import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory import gq.kirmanak.mealient.data.network.createServiceFactory
import gq.kirmanak.mealient.data.recipes.RecipeImageLoader import gq.kirmanak.mealient.data.recipes.RecipeImageLoader

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.impl.util package gq.kirmanak.mealient.extensions
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.impl.util package gq.kirmanak.mealient.extensions
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeEntity import gq.kirmanak.mealient.data.recipes.db.entity.RecipeEntity
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeIngredientEntity import gq.kirmanak.mealient.data.recipes.db.entity.RecipeIngredientEntity

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.impl.util package gq.kirmanak.mealient.extensions
import androidx.room.TypeConverter import androidx.room.TypeConverter
import kotlinx.datetime.* import kotlinx.datetime.*

View File

@@ -38,7 +38,9 @@ class AuthenticationViewModel @Inject constructor(
fun logout() { fun logout() {
Timber.v("logout() called") Timber.v("logout() called")
authRepo.logout() viewModelScope.launch {
viewModelScope.launch { recipeRepo.clearLocalData() } authRepo.logout()
recipeRepo.clearLocalData()
}
} }
} }

View File

@@ -36,8 +36,10 @@ class DisclaimerViewModel @Inject constructor(
fun acceptDisclaimer() { fun acceptDisclaimer() {
Timber.v("acceptDisclaimer() called") Timber.v("acceptDisclaimer() called")
disclaimerStorage.acceptDisclaimer() viewModelScope.launch {
_isAccepted.value = true disclaimerStorage.acceptDisclaimer()
_isAccepted.value = true
}
} }
fun startCountDown() { fun startCountDown() {

View File

@@ -5,7 +5,9 @@ import com.squareup.picasso.Picasso
import gq.kirmanak.mealient.ui.ImageLoader import gq.kirmanak.mealient.ui.ImageLoader
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ImageLoaderPicasso @Inject constructor( class ImageLoaderPicasso @Inject constructor(
private val picasso: Picasso private val picasso: Picasso
) : ImageLoader { ) : ImageLoader {

View File

@@ -8,7 +8,9 @@ import gq.kirmanak.mealient.BuildConfig
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PicassoBuilder @Inject constructor( class PicassoBuilder @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient private val okHttpClient: OkHttpClient

View File

@@ -3,7 +3,7 @@ package gq.kirmanak.mealient.data.auth.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.* import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.*
import gq.kirmanak.mealient.data.network.ServiceFactory import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.di.AppModule import gq.kirmanak.mealient.di.NetworkModule
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
@@ -33,7 +33,7 @@ class AuthDataSourceImplTest {
@Before @Before
fun setUp() { fun setUp() {
MockKAnnotations.init(this) MockKAnnotations.init(this)
subject = AuthDataSourceImpl(authServiceFactory, AppModule.createJson()) subject = AuthDataSourceImpl(authServiceFactory, NetworkModule.createJson())
} }
@Test @Test

View File

@@ -13,8 +13,8 @@ import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
import gq.kirmanak.mealient.test.RobolectricTest import gq.kirmanak.mealient.test.RobolectricTest
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@@ -102,12 +102,12 @@ class AuthRepoImplTest : RobolectricTest() {
dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD), eq(TEST_BASE_URL)) dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD), eq(TEST_BASE_URL))
} returns TEST_TOKEN } returns TEST_TOKEN
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, TEST_BASE_URL) subject.authenticate(TEST_USERNAME, TEST_PASSWORD, TEST_BASE_URL)
verify { storage.storeAuthData(TEST_AUTH_HEADER, TEST_BASE_URL) } coVerify { storage.storeAuthData(TEST_AUTH_HEADER, TEST_BASE_URL) }
} }
@Test @Test
fun `when logout then clearAuthData is called`() = runTest { fun `when logout then clearAuthData is called`() = runTest {
subject.logout() subject.logout()
verify { storage.clearAuthData() } coVerify { storage.clearAuthData() }
} }
} }

View File

@@ -0,0 +1,47 @@
package gq.kirmanak.mealient.data.storage
import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class PreferencesStorageImplTest : HiltRobolectricTest() {
@Inject
lateinit var subject: PreferencesStorage
@Test
fun `when getValue without writes then null`() = runTest {
assertThat(subject.getValue(subject.authHeaderKey)).isNull()
}
@Test(expected = IllegalStateException::class)
fun `when requireValue without writes then throws IllegalStateException`() = runTest {
subject.requireValue(subject.authHeaderKey)
}
@Test
fun `when getValue after write then returns value`() = runTest {
subject.storeValues(Pair(subject.authHeaderKey, "test"))
assertThat(subject.getValue(subject.authHeaderKey)).isEqualTo("test")
}
@Test
fun `when storeValue then valueUpdates emits`() = runTest {
subject.storeValues(Pair(subject.authHeaderKey, "test"))
assertThat(subject.valueUpdates(subject.authHeaderKey).first()).isEqualTo("test")
}
@Test
fun `when remove value then getValue returns null`() = runTest {
subject.storeValues(Pair(subject.authHeaderKey, "test"))
subject.removeValues(subject.authHeaderKey)
assertThat(subject.getValue(subject.authHeaderKey)).isNull()
}
}

View File

@@ -1,7 +1,6 @@
package gq.kirmanak.mealient.data.impl package gq.kirmanak.mealient.extensions
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.impl.util.RoomTypeConverters
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import org.junit.Test import org.junit.Test

View File

@@ -8,22 +8,16 @@ import org.junit.BeforeClass
import org.junit.Rule import org.junit.Rule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import timber.log.Timber
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@Config(application = HiltTestApplication::class, manifest = Config.NONE) @Config(application = HiltTestApplication::class, manifest = Config.NONE)
abstract class HiltRobolectricTest { abstract class HiltRobolectricTest {
companion object { companion object {
@BeforeClass @BeforeClass
@JvmStatic @JvmStatic
fun setupTimber() { fun setupTimber() = plantPrintLn()
Timber.plant(object : Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
println(message)
t?.printStackTrace()
}
})
}
} }
@get:Rule @get:Rule

View File

@@ -2,9 +2,18 @@ package gq.kirmanak.mealient.test
import android.app.Application import android.app.Application
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.BeforeClass
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@Config(application = Application::class, manifest = Config.NONE) @Config(application = Application::class, manifest = Config.NONE)
abstract class RobolectricTest abstract class RobolectricTest {
companion object {
@BeforeClass
@JvmStatic
fun setupTimber() = plantPrintLn()
}
}

View File

@@ -2,5 +2,15 @@ package gq.kirmanak.mealient.test
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.ResponseBody.Companion.toResponseBody
import timber.log.Timber
fun String.toJsonResponseBody() = toResponseBody("application/json".toMediaType()) fun String.toJsonResponseBody() = toResponseBody("application/json".toMediaType())
fun plantPrintLn() {
Timber.plant(object : Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
println(message)
t?.printStackTrace()
}
})
}