Skip to content

Commit

Permalink
[docs] readme touchup (#11)
Browse files Browse the repository at this point in the history
This is to include the essential codes for quick reference, plus minor
code refactoring for version 1.0.1
  • Loading branch information
ryanw-mobile authored Mar 19, 2024
2 parents 923ad3a + c6030da commit 45688c7
Show file tree
Hide file tree
Showing 9 changed files with 132 additions and 56 deletions.
86 changes: 84 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ This is an experimental project that tries to put the legacy SharedPreferences a
Preferences Datastore side by side by means of dependency inversion to see how it would look if it
were to provide the same preferences storage at the data layer.

<p><img src="hero.jpg" style="width: 100%; max-width: 1000px; height: auto;" alt="cover image" style="width: 100%; max-width: 1000px; height: auto;"></p>

## Background

In my first few Android apps, which date back to 2010, we did not have any architecture to follow.
Expand All @@ -26,10 +28,90 @@ observe preference changes, there is a known limitation: we are not being told w
changed. We know only _something_ has changed, so we probably have to propagate _all_ the keys we
are interested in, even if we know that only _one_ has changed.

## The minimum code to make SharedPreferences work

```
private val sharedPref = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
private val onPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPref, key ->
when (key) {
prefKeyString -> {
_stringPreference.value = sharedPref.getString(prefKeyString, null) ?: stringPreferenceDefault
}
prefKeyBoolean -> {
_booleanPreference.value = sharedPref.getBoolean(prefKeyBoolean, booleanPreferenceDefault)
}
}
}
init {
// If we want to keep track of the changes
sharedPref.registerOnSharedPreferenceChangeListener(onPreferenceChangeListener)
}
fun getStringPreference() = sharedPref.getString(prefKeyString, null) ?: "default-value"
fun updateStringPreference(newValue: String) {
try {
sharedPref.edit()
.putString(prefKeyString, newValue)
.apply()
} catch (e: Throwable) {
// Not likely to produce exception though
}
}
```

## The minimum code to make Jetpack Preferences Data Store work

```
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "preferences")
private val prefKeyStrings:Preferences.Key<String> = stringPreferencesKey("some-key-name")
init {
// assume caller passing in Context.dataStore as DataStore<Preferences>
externalCoroutineScope.launch(dispatcher) {
dataStore.data.catch { exception ->
_preferenceErrors.emit(exception)
}
.collect { prefs ->
// or use map
_stringPreference.value = prefs[prefKeyString] ?: stringPreferenceDefault
_booleanPreference.value = prefs[prefKeyBoolean] ?: booleanPreferenceDefault
_intPreference.value = prefs[prefKeyInt] ?: intPreferenceDefault
}
}
}
suspend fun updateStringPreference(newValue: String) {
withContext(dispatcher) {
try {
dataStore.edit { mutablePreferences ->
mutablePreferences[prefKeyString] = newValue
}
} catch (e: Throwable) {
_preferenceErrors.emit(e)
}
}
}
```

## Approach

Whether for SharedPreferences or Jetpack Preferences Datastore, even if the core is about 10 lines
of code, this code project tries to put them in the right place when following the MVVM and Clean
architecture. That means the UI will reach the ViewModel, which will then connect to a repository
architecture. That means the UI will talk to the ViewModel, which will then connect to a repository
that invisibly talks to either the SharedPreferences or the Jetpack Preferences Datastore data
store.
store. Dependency inversion with Dagger Hilt allows injecting different data sources (
SharedPreferences and Jetpack Preferences Data Store) into the same repository. Usually in
production apps it is not likely that we have a need to use both sources interchangeably.

## Let's download and run it!

This project was configured to build using Android Studio Iguana | 2023.2.1. You will need to have
Java 17 to build the project.

Alternatively, you can find the ready-to-install APKs and App Bundles under
the [release section](https://github.com/ryanw-mobile/FusedUserPreferences/releases).
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ android {
applicationId = "com.rwmobi.fuseduserpreferences"
minSdk = libs.versions.minsdk.get().toInt()
targetSdk = libs.versions.targetsdk.get().toInt()
versionCode = 1
versionName = "1.0"
versionCode = 2
versionName = "1.0.1"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ package com.rwmobi.fuseduserpreferences.data.datasources.preferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import com.rwmobi.fuseduserpreferences.di.DispatcherModule
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

class PreferencesDataStoreWrapper(
private val dataStore: DataStore<Preferences>,
Expand All @@ -24,6 +27,7 @@ class PreferencesDataStoreWrapper(
private val booleanPreferenceDefault: Boolean,
private val intPreferenceDefault: Int,
externalCoroutineScope: CoroutineScope,
@DispatcherModule.IoDispatcher private val dispatcher: CoroutineDispatcher,
) : com.rwmobi.fuseduserpreferences.data.datasources.preferences.Preferences {
private val _stringPreference = MutableStateFlow(stringPreferenceDefault)
override val stringPreference = _stringPreference.asStateFlow()
Expand All @@ -38,7 +42,7 @@ class PreferencesDataStoreWrapper(
override val preferenceErrors = _preferenceErrors.asSharedFlow()

init {
externalCoroutineScope.launch {
externalCoroutineScope.launch(dispatcher) {
dataStore.data.catch { exception ->
_preferenceErrors.emit(exception)
}
Expand All @@ -51,42 +55,50 @@ class PreferencesDataStoreWrapper(
}

override suspend fun updateStringPreference(newValue: String) {
try {
dataStore.edit { mutablePreferences ->
mutablePreferences[prefKeyString] = newValue
withContext(dispatcher) {
try {
dataStore.edit { mutablePreferences ->
mutablePreferences[prefKeyString] = newValue
}
} catch (e: Throwable) {
_preferenceErrors.emit(e)
}
} catch (e: Throwable) {
_preferenceErrors.emit(e)
}
}

override suspend fun updateBooleanPreference(newValue: Boolean) {
try {
dataStore.edit { mutablePreferences ->
mutablePreferences[prefKeyBoolean] = newValue
withContext(dispatcher) {
try {
dataStore.edit { mutablePreferences ->
mutablePreferences[prefKeyBoolean] = newValue
}
} catch (e: Throwable) {
_preferenceErrors.emit(e)
}
} catch (e: Throwable) {
_preferenceErrors.emit(e)
}
}

override suspend fun updateIntPreference(newValue: Int) {
try {
dataStore.edit { mutablePreferences ->
mutablePreferences[prefKeyInt] = newValue
withContext(dispatcher) {
try {
dataStore.edit { mutablePreferences ->
mutablePreferences[prefKeyInt] = newValue
}
} catch (e: Throwable) {
_preferenceErrors.emit(e)
}
} catch (e: Throwable) {
_preferenceErrors.emit(e)
}
}

override suspend fun clear() {
try {
dataStore.edit { mutablePreferences ->
mutablePreferences.clear()
withContext(dispatcher) {
try {
dataStore.edit { mutablePreferences ->
mutablePreferences.clear()
}
} catch (e: Throwable) {
_preferenceErrors.emit(e)
}
} catch (e: Throwable) {
_preferenceErrors.emit(e)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,29 @@
package com.rwmobi.fuseduserpreferences.data.repositories

import com.rwmobi.fuseduserpreferences.data.datasources.preferences.Preferences
import com.rwmobi.fuseduserpreferences.di.DispatcherModule
import com.rwmobi.fuseduserpreferences.domain.repositories.UserPreferencesRepository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext

// This looks a bit redundant, but ChatGPT says this confirms to the Clean Architecture
class UserPreferencesRepositoryImpl(
private val preferences: Preferences,
@DispatcherModule.IoDispatcher private val dispatcher: CoroutineDispatcher,
) : UserPreferencesRepository {
class UserPreferencesRepositoryImpl(private val preferences: Preferences) : UserPreferencesRepository {

override val stringPreference = preferences.stringPreference
override val booleanPreference = preferences.booleanPreference
override val intPreference = preferences.intPreference
override val preferenceErrors = preferences.preferenceErrors

override suspend fun updateStringPreference(newValue: String) {
withContext(dispatcher) {
preferences.updateStringPreference(newValue)
}
preferences.updateStringPreference(newValue)
}

override suspend fun updateBooleanPreference(newValue: Boolean) {
withContext(dispatcher) {
preferences.updateBooleanPreference(newValue)
}
preferences.updateBooleanPreference(newValue)
}

override suspend fun updateIntPreference(newValue: Int) {
withContext(dispatcher) {
preferences.updateIntPreference(newValue)
}
preferences.updateIntPreference(newValue)
}

override suspend fun clear() {
withContext(dispatcher) {
preferences.clear()
}
preferences.clear()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = PRE
object AppModule {
@Provides
@Singleton
fun provideApplicationScope(@ApplicationContext context: Context): CoroutineScope {
fun provideApplicationScope(): CoroutineScope {
return CoroutineScope(
context = SupervisorJob() + Dispatchers.Default + CoroutineExceptionHandler { _, throwable ->
// Handle uncaught exceptions from this scope.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import javax.inject.Singleton

Expand All @@ -29,6 +30,7 @@ object DataSourcesModule {
fun providePreferencesDataStore(
dataStore: DataStore<androidx.datastore.preferences.core.Preferences>,
externalCoroutineScope: CoroutineScope,
@DispatcherModule.IoDispatcher dispatcher: CoroutineDispatcher,
): Preferences {
return PreferencesDataStoreWrapper(
dataStore = dataStore,
Expand All @@ -39,6 +41,7 @@ object DataSourcesModule {
booleanPreferenceDefault = DefaultValues.BOOLEAN_PREFERENCE,
intPreferenceDefault = DefaultValues.INT_PREFERENCE,
externalCoroutineScope = externalCoroutineScope,
dispatcher = dispatcher,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.CoroutineDispatcher
import javax.inject.Qualifier

@Qualifier
Expand All @@ -31,24 +30,16 @@ object RepositoryModule {
@ViewModelScoped
fun providePreferencesDataStoreRepository(
@PreferencesDataStore preferences: Preferences,
@DispatcherModule.IoDispatcher dispatcher: CoroutineDispatcher,
): UserPreferencesRepository {
return UserPreferencesRepositoryImpl(
preferences = preferences,
dispatcher = dispatcher,
)
return UserPreferencesRepositoryImpl(preferences = preferences)
}

@SharedPreferences
@Provides
@ViewModelScoped
fun provideSharedPreferencesRepository(
@SharedPreferences preferences: Preferences,
@DispatcherModule.IoDispatcher dispatcher: CoroutineDispatcher,
): UserPreferencesRepository {
return UserPreferencesRepositoryImpl(
preferences = preferences,
dispatcher = dispatcher,
)
return UserPreferencesRepositoryImpl(preferences = preferences)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
Expand Down Expand Up @@ -49,6 +50,7 @@ class PreferencesDataStoreWrapperTest {
stringPreferenceDefault = stringPreferenceDefault,
booleanPreferenceDefault = booleanPreferenceDefault,
intPreferenceDefault = intPreferenceDefault,
dispatcher = Dispatchers.Unconfined,
)
}

Expand Down
Binary file added hero.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 45688c7

Please sign in to comment.