Replace Shared Preferences with Data Store

This commit is contained in:
Kirill Kamakin
2022-04-03 19:55:31 +05:00
parent fd9f7e5aa1
commit b3e25db4df
17 changed files with 244 additions and 144 deletions

View File

@@ -120,9 +120,6 @@ dependencies {
// https://github.com/Kotlin/kotlinx.serialization/releases
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
implementation 'com.jakewharton.timber:timber:5.0.1'
@@ -145,6 +142,12 @@ dependencies {
// https://github.com/square/picasso/releases
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
testImplementation "junit:junit:4.13.2"
@@ -163,9 +166,6 @@ dependencies {
// https://mockk.io/
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
def flipper_version = "0.140.0"
debugImplementation "com.facebook.flipper:flipper:$flipper_version"

View File

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

View File

@@ -15,5 +15,5 @@ interface AuthRepo {
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
interface AuthStorage {
fun storeAuthData(authHeader: String, baseUrl: String)
suspend fun storeAuthData(authHeader: String, baseUrl: String)
suspend fun getBaseUrl(): String?
@@ -11,5 +11,5 @@ interface AuthStorage {
fun authHeaderObservable(): Flow<String?>
fun clearAuthData()
suspend fun clearAuthData()
}

View File

@@ -46,7 +46,7 @@ class AuthRepoImpl @Inject constructor(
return storage.authHeaderObservable().map { it != null }
}
override fun logout() {
override suspend fun logout() {
Timber.v("logout() called")
storage.clearAuthData()
}

View File

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

View File

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

View File

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

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,27 +1,39 @@
package gq.kirmanak.mealient.di
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.room.Room
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.AppDb
import gq.kirmanak.mealient.data.storage.PreferencesStorage
import gq.kirmanak.mealient.data.storage.PreferencesStorageImpl
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun createDb(@ApplicationContext context: Context): AppDb =
Room.databaseBuilder(context, AppDb::class.java, "app.db").build()
interface AppModule {
companion object {
@Provides
@Provides
@Singleton
fun createDb(@ApplicationContext context: Context): AppDb =
Room.databaseBuilder(context, AppDb::class.java, "app.db").build()
@Provides
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("settings") }
}
@Binds
@Singleton
fun createSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
PreferenceManager.getDefaultSharedPreferences(context)
fun bindPreferencesStorage(preferencesStorage: PreferencesStorageImpl): PreferencesStorage
}

View File

@@ -1,36 +0,0 @@
package gq.kirmanak.mealient.extensions
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

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

View File

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

View File

@@ -13,8 +13,8 @@ import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
import gq.kirmanak.mealient.test.RobolectricTest
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
@@ -102,12 +102,12 @@ class AuthRepoImplTest : RobolectricTest() {
dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD), eq(TEST_BASE_URL))
} returns TEST_TOKEN
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
fun `when logout then clearAuthData is called`() = runTest {
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,6 +1,6 @@
package gq.kirmanak.mealient.extensions
import com.google.common.truth.Truth
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import org.junit.Test
@@ -10,27 +10,27 @@ class RoomTypeConvertersTest {
fun `when localDateTimeToTimestamp then correctly converts`() {
val input = LocalDateTime.parse("2021-11-13T15:56:33")
val actual = RoomTypeConverters.localDateTimeToTimestamp(input)
Truth.assertThat(actual).isEqualTo(1636818993000)
assertThat(actual).isEqualTo(1636818993000)
}
@Test
fun `when timestampToLocalDateTime then correctly converts`() {
val expected = LocalDateTime.parse("2021-11-13T15:58:38")
val actual = RoomTypeConverters.timestampToLocalDateTime(1636819118000)
Truth.assertThat(actual).isEqualTo(expected)
assertThat(actual).isEqualTo(expected)
}
@Test
fun `when localDateToTimeStamp then correctly converts`() {
val input = LocalDate.parse("2021-11-13")
val actual = RoomTypeConverters.localDateToTimeStamp(input)
Truth.assertThat(actual).isEqualTo(1636761600000)
assertThat(actual).isEqualTo(1636761600000)
}
@Test
fun `when timestampToLocalDate then correctly converts`() {
val expected = LocalDate.parse("2021-11-13")
val actual = RoomTypeConverters.timestampToLocalDate(1636761600000)
Truth.assertThat(actual).isEqualTo(expected)
assertThat(actual).isEqualTo(expected)
}
}