Proto DataStore + AndroidX Preferences on Kotlin

Almost a year has passed since the Google AndroidX team presented a new DataStore library to replace the SharedPreferences library , but popularization of the new lib is clearly not an active task. Otherwise, I cannot explain 1) an incomplete guide, following only which, you will not build a project at all due to the lack of all the necessary dependencies and additional build tasks for the build system, and 2) the absence of non-hello-world similar examples in CodeLabs, except for one, and then, sharpened not for an example of using the library from scratch, but for migration from SharedPreferences to the Preferences DataStore... Similarly, all articles on Medium, literally or in other words, repeat everything that is written in the Google Guide, or use the wrong approaches for working with the DataStore, suggesting wrap the asynchronous io code in runBlocking right on the ui thread.





And it would also be nice to connect the "rear" with the "front", so to speak: Google has the AndroidX Preferences library from the Jetpack clip, which allows you to throw in a ready-made material-design fragment in two clicks to manage application settings and, in a favorite way of code generation, free the developer from writing boilerplate ... However, this library as a repository suggests using the now outdated SharedPreferences, and there is no official guide for connecting to the DataStore. In this note, I would like to eliminate the two described shortcomings in my own way.





Creating a framework for working with the DataStore

The DataStore library is divided into two parts: an analogue of the previous one called Preferences DataStore, which stores settings values ​​in key-value pairs and is not type-safe, and the second, which stores settings in a Protocol buffers file and is type-safe. It is more flexible and versatile, so I chose it for my experiments.





To describe the settings scheme, you need to create an additional file in the project. First, you need to switch the studio or idea explorer to Project mode so that the entire folder structure is visible, and then create a file with the * .proto extension in the app / src / main / proto / folder (and not pb, as Google recommends - with Neither a plugin for syntax checking, autocompletion, etc., nor a build task that generates the corresponding class will work).





Protocol buffer Google, . , :





syntax = "proto3";

option java_package = "...";
option java_multiple_files = true;

message ProtoSettings {
  bool translate_to_ru = 1;
  map<string, int64> last_sync = 2;
  int32 refresh_interval = 3;
}
      
      



, , , - -long Kotlin, unix- ( c data , simple name ).





build.gradle- :





plugins {
    ...
    id "com.google.protobuf" version "0.8.12"
}
...
dependencies {
	  ...
    //DataStore
    implementation "androidx.datastore:datastore:1.0.0-beta01"
    implementation "com.google.protobuf:protobuf-javalite:3.11.0"
    implementation "androidx.preference:preference-ktx:1.1.1"
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.11.0"
    }

    generateProtoTasks {
        all().each { task ->
            task.builtins {
                java {
                    option 'lite'
                }
            }
        }
    }
}

      
      



proto- , java DataStore proto.





DataStore: / , Flow. set- builder. Flow , , , collect & Co .





! deprecated- Flow toList toSet, (flow never completes, so this terminal operation never completes).





boilerplate , . , Google , :





@Suppress("BlockingMethodInNonBlockingContext")
object SettingsSerializer : Serializer<ProtoSettings> {
    override val defaultValue: ProtoSettings = ProtoSettings.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): ProtoSettings {
        return try {
            ProtoSettings.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            Log.e("SETTINGS", "Cannot read proto. Create default.")
            defaultValue
        }
    }

    override suspend fun writeTo(t: ProtoSettings, output: OutputStream) = t.writeTo(output)
}
      
      



Serializer ( ) .





- , : -, , , -, , , -, Hilt :





class Settings @Inject constructor(val settings: DataStore<ProtoSettings>) {

  companion object {
        const val HOUR_TO_MILLIS = 60 * 60 * 1000   // hours to milliseconds
        const val TRANSLATE_SWITCH = "translate_to_ru"
        const val REFRESH_INTERVAL_BAR = "refresh_interval"
        const val IS_PREFERENCES_CHANGED = "preferences_changed"
    }
  
    val saved get() = settings.data.take(1)
    
    suspend fun translateToRu(value: Boolean) = settings.updateData {
        it.toBuilder().setTranslateToRu(value).build()
    }

    suspend fun saveLastSync(cls: String) = settings.updateData {
        it.toBuilder().putLastSync(cls, System.currentTimeMillis()).build()
    }

    suspend fun refreshInterval(hours: Int) = settings.updateData {
        it.toBuilder().setRefreshInterval(hours * HOUR_TO_MILLIS).build()
    }

    fun checkNeedSync(cls: String) = saved.map {
        it.lastSyncMap[cls]?.run {
            System.currentTimeMillis() - this > saved.refreshInterval
        } ?: true
    }
}

@Module
@InstallIn(SingletonComponent::class)
class SettingsModule {

    @Provides
    @Singleton
    fun provideSettings(@ApplicationContext context: Context) = Settings(context.dataStore)

    private val Context.dataStore: DataStore<ProtoSettings> by dataStore(
        fileName = "settings.proto",
        serializer = SettingsSerializer
    )
}
      
      



, saved, flow take(1). , , . collect, , , emit . first(), flow . last(), , .. flow.





DataStore

. , , . Kotlin , sealed :





sealed class Result
    data class Success<out T>(val data: T): Result()
    data class Error(val msg: String, val error: ErrorType): Result()
    object Loading : Result()
      
      



, :





fun <T> fetchItems(
        itemsType: String,
        remoteApiCallback: suspend () -> Response<ApiResponse<T>>,
        localApiCallback: suspend () -> List<T>,
        saveApiCallback: suspend (List<T>) -> Unit,
    ): Flow<Result> = settings.checkNeedSync(itemsType).transform { needSync ->
        var remoteFailed = true
        emit(Loading)
        localApiCallback().let { local ->
            if (needSync || local.isEmpty()) {
                if (networkHelper.isNetworkConnected()) {
                    remoteApiCallback().apply {
                        if (isSuccessful) body()?.docs?.let { remote ->
                            settings.saveLastSync(itemsType)
                            remoteFailed = false
                            emit(Success(remote))
                            saveApiCallback(remote)
                        }
                        else emit(Error(errorBody().toString(), ErrorType.REMOTE_API_ERROR))
                    }
                } else emit(Error("No internet connection!", ErrorType.NO_INTERNET_CONNECTION))
            }

            if (remoteFailed)
                emit(if (local.isNotEmpty()) Success(local) else Error("No local saved data", ErrorType.NO_SAVED_DATA))
        }
    }
        .flowOn(Dispatchers.IO)
        .catch { e ->
            ...
        }
      
      



( ) : , . :





fun getSomething() = fetchItems<Something>("Something", remoteApi::getSomething, localApi::getSomething, localApi::saveSomething)
fun getSmthOther() = fetchItems<Other>("Other", remoteApi::getSmthOther, localApi::getSmthOther, localApi::saveSmthOther)
    
      
      



, reified , , T::class.simpleName, inline, crossinline/noinline, . inline , , /, .





checkNeedSync flow, SettingsRepository, flow Result transform. : Loading ( ui - ), . , , , . , checkNeedSync (take (1)), emit - checkNeedSync fetchItems. - , , , . , .





androidX . AndroidX Preference User interface/Settings, SharedPreferences ( Google DataStore PreferenceDataStore).





preferences.xml
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:android="http://schemas.android.com/apk/res/android">

    <PreferenceCategory android:title="@string/experimentalTitle">

        <SwitchPreferenceCompat
            android:defaultValue="false"
            android:key="translate_to_ru"
            android:summaryOff="@string/aiTranslateOffText"
            android:summaryOn="@string/aiTranslateOnText"
            android:title="@string/aiTranslateTitle" />
    </PreferenceCategory>
    <PreferenceCategory android:title="@string/synchronizeTitle">

        <SeekBarPreference
            android:defaultValue="2"
            android:key="refresh_interval"
            android:title="@string/refreshIntervalTitle"
            android:summary="@string/refreshSummary"
            android:max="24"
            app:min="0"
            app:seekBarIncrement="1"
            app:showSeekBarValue="true" />
    </PreferenceCategory>
</PreferenceScreen>
      
      



:





material design , guides. , summaryOff/summaryOn - , , . default value. key, .





Navigation . , , :





override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when (item.itemId) {
            ...
            R.id.preferences -> findNavController().navigate(MainFragmentDirections.actionShowPreferences())
        }
        return super.onOptionsItemSelected(item)
    }
      
      



( , , ), Navigation SavedStateHandle, onCreateView observer BackStack':





findNavController().currentBackStackEntry?.let {
            it.savedStateHandle.getLiveData<Boolean>(Settings.IS_PREFERENCES_CHANGED).observe(viewLifecycleOwner) { isChanged ->
                if (isChanged) {
                    viewModel.armRefresh()
                    it.savedStateHandle.remove<Boolean>(Settings.IS_PREFERENCES_CHANGED)
                }
            }
        }
      
      



, , .. LiveData, , .





, DataStore savedStateHandle . findPreference, findViewById, setOnPreferenceChangeListener:





override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
        setPreferencesFromResource(R.xml.preferences, rootKey)
        requireActivity().title = getString(R.string.preferencesTitle)

        val translateSwitch = findPreference<SwitchPreferenceCompat>(Settings.TRANSLATE_SWITCH)?.apply {
            setOnPreferenceChangeListener { _, value ->
                lifecycleScope.launch { settings.translateToRu(value as Boolean) }
                findNavController().previousBackStackEntry?.let {
                    it.savedStateHandle[Settings.IS_PREFERENCES_CHANGED] = true
                }
                true
            }
        }

        val refreshSeekBar = findPreference<SeekBarPreference>(Settings.REFRESH_INTERVAL_BAR)?.apply {
            setOnPreferenceChangeListener { _, value ->
                lifecycleScope.launch { settings.refreshInterval(value as Int) }
                findNavController().previousBackStackEntry?.let {
                    it.savedStateHandle[Settings.IS_PREFERENCES_CHANGED] = true
                }
                true
            }
        }

        settings.saved.collectOnFragment(this) {
            translateSwitch?.isChecked = it.translateToRu
            refreshSeekBar?.value = it.refreshInterval / Settings.HOUR_TO_MILLIS
        }
    }
      
      



collectOnFragment flow
fun <T> Flow<T>.collectOnFragment(
    fragment: Fragment,
    state: Lifecycle.State = Lifecycle.State.RESUMED,
    block: (T) -> Unit
) {
    fragment.lifecycleScope.launch {
        flowWithLifecycle(fragment.lifecycle, state)
            .collect {
                block(it)
            }
    }
}
      
      



, setOnPreferenceChangeListener value Any, value as Boolean value as Int, .





. , Kotlin DataStore, runBlocking , 4-min-to-read- ( Google, ).





, Jetpack- ui c material design .





There are places in the code sections that I did not begin to explain or cite completely due to unimportance or obviousness (for example, the value of the HOUR_TO_MILLIS constant), but if you cannot build a similar project according to my recipe, write in the comments, I will try to add all the obscure places ... Note that I took all parts of the code from a fully working and tested project, so you shouldn't worry about its performance.





Thanks for reading.








All Articles