Skip to content

Commit

Permalink
[feature] everything (#10)
Browse files Browse the repository at this point in the history
Completing everything. Workable app just pending readme touchups.
  • Loading branch information
ryanw-mobile authored Mar 18, 2024
2 parents 8cd15d4 + 841fe4d commit 66439d4
Show file tree
Hide file tree
Showing 35 changed files with 1,464 additions and 315 deletions.
32 changes: 24 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,35 @@
# Fused User Preferences

This is an experimental project, trying to put the legacy SharedPreferences and the new Jetpack Preferences Datastore side by side by means of dependency inversion, to see how it would look if it were to provide the same preferences storages at the data layer.
This is an experimental project that tries to put the legacy SharedPreferences and the new Jetpack
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.

## Background

In my first few Android apps dated back to 2010, we did not have any architecture to follow. We did not have fragments, so mostly we allocate an activity class for each screen. There, if we want to deal with user preferences, it was so easy that we could have placed the code under onResume or even onCreated. SharedPreferences is non-blocking, so that when it does not break, it works quick and simple for most small use cases.
In my first few Android apps, which date back to 2010, we did not have any architecture to follow.
We did not have fragments, so we mostly allocated an activity class for each screen. There, if we
wanted to deal with user preferences, it was so easy that we could have placed the code under
onResume or even onCreated. SharedPreferences is non-blocking, so it works quickly and simply for
most small use cases when it does not break.

Later on, people suggested that being non-blocking (and synchronus) can be a problem.
Later on, people came up with more different architectures.
- Later, people suggested that SharedPreferences being synchronous can be a problem. That is
sensible when developers abuse SharedPreferences by storing a massive amount of key pairs.
- Later, people came up with more different architectures, so we are not simply accessing user
preferences right from the activity class.

Eventually if we want to access user preferences, we have many more boilerplate code before actually executing the few lines that really do the work.
Eventually, if we want to access user preferences, we can have many more boilerplate codes before
executing the few lines that do the work.

Now we have Jetpack Preference Datastore. It is asynchrous, which means when we want to retrieve user perference, the result is not immediately available. Up to this moment, if we want to observe preferences changes, there is a known limitation that we are not being told which preference key has changed. We know only _something_ has changed, so that we probably have to propagate _all_ the keys we are interested in, whenever we know _one_ of them has changed.
Now we have Jetpack Preference Datastore. It is asynchronous, which means that when we want to
retrieve user preferences, the result is not immediately available. Up to this moment, if we want to
observe preference changes, there is a known limitation: we are not being told which key pair has
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.

## Approach

No matter it is for SharedPreferences or Jetpack Preferences Datastore, even the core is just about less than 10 lines of code, this code project tries to put them to 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 that invisiblty talk to either the SharedPreferences or the Jetpack Preferences Datastore data store.

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
that invisibly talks to either the SharedPreferences or the Jetpack Preferences Datastore data
store.
12 changes: 12 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import java.util.Properties
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.hilt.android.plugin)
alias(libs.plugins.devtools.ksp)
alias(libs.plugins.gradleKtlint)
}

Expand Down Expand Up @@ -181,6 +183,8 @@ dependencies {
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.junit.ktx)
implementation(libs.timber)
implementation(libs.androidx.lifecycle.runtime.compose)
testImplementation(libs.junit)
testImplementation(libs.junit.vintage.engine)
testImplementation(libs.kotlinx.coroutines.test)
Expand All @@ -194,4 +198,12 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)

// Dagger-Hilt
// Hilt does not support ksp yet https://issuetracker.google.com/issues/179057202?pli=1
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
kspAndroidTest(libs.hilt.android.compiler)
androidTestImplementation(libs.hilt.android.testing)
}
4 changes: 2 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2024. Ryan Wong ([email protected])
-->

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application
android:name=".FusedUserPreferencesApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2024. Ryan Wong ([email protected])
*/

package com.rwmobi.fuseduserpreferences

import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber

@HiltAndroidApp
class FusedUserPreferencesApp : Application() {

override fun onCreate() {
super.onCreate()

if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
} else {
// TODO: If Firebase Crashlytics is available, replace with a CrashReportingTree here
Timber.plant(Timber.DebugTree())
}
}
}
55 changes: 31 additions & 24 deletions app/src/main/java/com/rwmobi/fuseduserpreferences/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,43 +8,50 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.rememberNavController
import com.rwmobi.fuseduserpreferences.ui.components.BottomNavigationBar
import com.rwmobi.fuseduserpreferences.ui.screens.MainScreen
import com.rwmobi.fuseduserpreferences.ui.theme.FusedUserPreferencesTheme
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()

FusedUserPreferencesTheme {
// A surface container using the 'background' color from the theme
Surface(
Scaffold(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Greeting("Android")
bottomBar = {
NavigationBar {
BottomNavigationBar(navController = navController)
}
},
) { padding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(padding),
color = MaterialTheme.colorScheme.background,
) {
MainScreen(
modifier = Modifier
.fillMaxSize()
.padding(all = 16.dp),
navController = navController,
)
}
}
}
}
}
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier,
)
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
FusedUserPreferencesTheme {
Greeting("Android")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,12 @@
* Copyright (c) 2024. Ryan Wong ([email protected])
*/

package com.rwmobi.fuseduserpreferences.data.datasources
package com.rwmobi.fuseduserpreferences.data.datasources.preferences

import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow

const val PREF_KEY_STRING = "keyString"
const val PREF_KEY_BOOLEAN = "keyBoolean"
const val PREF_KEY_INT = "keyInt"

interface AppPreferences {
interface Preferences {

val stringPreference: StateFlow<String>
val booleanPreference: StateFlow<Boolean>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,11 @@
* Copyright (c) 2024. Ryan Wong ([email protected])
*/

package com.rwmobi.fuseduserpreferences.data.datasources.prefdatastore
package com.rwmobi.fuseduserpreferences.data.datasources.preferences

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.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.rwmobi.fuseduserpreferences.data.datasources.AppPreferences
import com.rwmobi.fuseduserpreferences.data.datasources.PREF_KEY_BOOLEAN
import com.rwmobi.fuseduserpreferences.data.datasources.PREF_KEY_INT
import com.rwmobi.fuseduserpreferences.data.datasources.PREF_KEY_STRING
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
Expand All @@ -22,13 +15,16 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch

class PreferenceDataStoreWrapper(
class PreferencesDataStoreWrapper(
private val dataStore: DataStore<Preferences>,
private val prefKeyString: Preferences.Key<String>,
private val prefKeyBoolean: Preferences.Key<Boolean>,
private val prefKeyInt: Preferences.Key<Int>,
private val stringPreferenceDefault: String,
private val booleanPreferenceDefault: Boolean,
private val intPreferenceDefault: Int,
externalCoroutineScope: CoroutineScope,
private val stringPreferenceDefault: String = "",
private val booleanPreferenceDefault: Boolean = false,
private val intPreferenceDefault: Int = 0,
) : AppPreferences {
) : com.rwmobi.fuseduserpreferences.data.datasources.preferences.Preferences {
private val _stringPreference = MutableStateFlow(stringPreferenceDefault)
override val stringPreference = _stringPreference.asStateFlow()

Expand All @@ -41,29 +37,23 @@ class PreferenceDataStoreWrapper(
private val _preferenceErrors = MutableSharedFlow<Throwable>()
override val preferenceErrors = _preferenceErrors.asSharedFlow()

companion object {
val DATASTORE_PREF_KEY_STRING = stringPreferencesKey(PREF_KEY_STRING)
val DATASTORE_PREF_KEY_BOOLEAN = booleanPreferencesKey(PREF_KEY_BOOLEAN)
val DATASTORE_PREF_KEY_INT = intPreferencesKey(PREF_KEY_INT)
}

init {
externalCoroutineScope.launch {
dataStore.data.catch { exception ->
_preferenceErrors.emit(exception)
}
.collect { prefs ->
_stringPreference.value = prefs[DATASTORE_PREF_KEY_STRING] ?: stringPreferenceDefault
_booleanPreference.value = prefs[DATASTORE_PREF_KEY_BOOLEAN] ?: booleanPreferenceDefault
_intPreference.value = prefs[DATASTORE_PREF_KEY_INT] ?: intPreferenceDefault
_stringPreference.value = prefs[prefKeyString] ?: stringPreferenceDefault
_booleanPreference.value = prefs[prefKeyBoolean] ?: booleanPreferenceDefault
_intPreference.value = prefs[prefKeyInt] ?: intPreferenceDefault
}
}
}

override suspend fun updateStringPreference(newValue: String) {
try {
dataStore.edit { mutablePreferences ->
mutablePreferences[DATASTORE_PREF_KEY_STRING] = newValue
mutablePreferences[prefKeyString] = newValue
}
} catch (e: Throwable) {
_preferenceErrors.emit(e)
Expand All @@ -73,7 +63,7 @@ class PreferenceDataStoreWrapper(
override suspend fun updateBooleanPreference(newValue: Boolean) {
try {
dataStore.edit { mutablePreferences ->
mutablePreferences[DATASTORE_PREF_KEY_BOOLEAN] = newValue
mutablePreferences[prefKeyBoolean] = newValue
}
} catch (e: Throwable) {
_preferenceErrors.emit(e)
Expand All @@ -83,7 +73,7 @@ class PreferenceDataStoreWrapper(
override suspend fun updateIntPreference(newValue: Int) {
try {
dataStore.edit { mutablePreferences ->
mutablePreferences[DATASTORE_PREF_KEY_INT] = newValue
mutablePreferences[prefKeyInt] = newValue
}
} catch (e: Throwable) {
_preferenceErrors.emit(e)
Expand Down
Loading

0 comments on commit 66439d4

Please sign in to comment.