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 // 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

@@ -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

@@ -46,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,55 +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.extensions.changesFlow import gq.kirmanak.mealient.data.storage.PreferencesStorage
import gq.kirmanak.mealient.extensions.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 import javax.inject.Singleton
private const val AUTH_HEADER_KEY = "AUTH_TOKEN"
private const val BASE_URL_KEY = "BASE_URL"
@Singleton @Singleton
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,29 +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.extensions.getBooleanOrFalse
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val IS_DISCLAIMER_ACCEPTED_KEY = "IS_DISCLAIMER_ACCEPTED"
@Singleton @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

@@ -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,20 +1,27 @@
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.storage.PreferencesStorage
import gq.kirmanak.mealient.data.storage.PreferencesStorageImpl
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object AppModule { interface AppModule {
companion object {
@Provides @Provides
@Singleton @Singleton
fun createDb(@ApplicationContext context: Context): AppDb = fun createDb(@ApplicationContext context: Context): AppDb =
@@ -22,6 +29,11 @@ object AppModule {
@Provides @Provides
@Singleton @Singleton
fun createSharedPreferences(@ApplicationContext context: Context): SharedPreferences = fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceManager.getDefaultSharedPreferences(context) PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("settings") }
}
@Binds
@Singleton
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() { fun logout() {
Timber.v("logout() called") Timber.v("logout() called")
viewModelScope.launch {
authRepo.logout() authRepo.logout()
viewModelScope.launch { recipeRepo.clearLocalData() } recipeRepo.clearLocalData()
}
} }
} }

View File

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

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,6 +1,6 @@
package gq.kirmanak.mealient.extensions 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.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import org.junit.Test import org.junit.Test
@@ -10,27 +10,27 @@ class RoomTypeConvertersTest {
fun `when localDateTimeToTimestamp then correctly converts`() { fun `when localDateTimeToTimestamp then correctly converts`() {
val input = LocalDateTime.parse("2021-11-13T15:56:33") val input = LocalDateTime.parse("2021-11-13T15:56:33")
val actual = RoomTypeConverters.localDateTimeToTimestamp(input) val actual = RoomTypeConverters.localDateTimeToTimestamp(input)
Truth.assertThat(actual).isEqualTo(1636818993000) assertThat(actual).isEqualTo(1636818993000)
} }
@Test @Test
fun `when timestampToLocalDateTime then correctly converts`() { fun `when timestampToLocalDateTime then correctly converts`() {
val expected = LocalDateTime.parse("2021-11-13T15:58:38") val expected = LocalDateTime.parse("2021-11-13T15:58:38")
val actual = RoomTypeConverters.timestampToLocalDateTime(1636819118000) val actual = RoomTypeConverters.timestampToLocalDateTime(1636819118000)
Truth.assertThat(actual).isEqualTo(expected) assertThat(actual).isEqualTo(expected)
} }
@Test @Test
fun `when localDateToTimeStamp then correctly converts`() { fun `when localDateToTimeStamp then correctly converts`() {
val input = LocalDate.parse("2021-11-13") val input = LocalDate.parse("2021-11-13")
val actual = RoomTypeConverters.localDateToTimeStamp(input) val actual = RoomTypeConverters.localDateToTimeStamp(input)
Truth.assertThat(actual).isEqualTo(1636761600000) assertThat(actual).isEqualTo(1636761600000)
} }
@Test @Test
fun `when timestampToLocalDate then correctly converts`() { fun `when timestampToLocalDate then correctly converts`() {
val expected = LocalDate.parse("2021-11-13") val expected = LocalDate.parse("2021-11-13")
val actual = RoomTypeConverters.timestampToLocalDate(1636761600000) val actual = RoomTypeConverters.timestampToLocalDate(1636761600000)
Truth.assertThat(actual).isEqualTo(expected) assertThat(actual).isEqualTo(expected)
} }
} }