Using Proto Datastore in Jetpack compose with Hilt

Using Proto Datastore in Jetpack compose with Hilt

What is Proto Datastore:

One of the downsides of SharedPreferences and Preferences DataStore is that there is no way to define a schema or to ensure that keys are accessed with the correct type. Proto DataStore addresses this problem by using Protocol buffers to define the schema. Using protos, DataStore knows what types are stored and will just provide them, removing the need for using keys.

Overview:

When I started implementing Proto Datastore in my project, I was using Hilt and MVVM architecture. So, while following the Codelab I was not clear on how I can define it in my module and Inject it in repos.

In this article, I will show you how you can use Proto Datastore in your Jetpack compose project while using Hilt and MVVM architecture. I have followed some practices that are already implemented in the Now in Android sample project provided for best practices reference.

Dependencies:

In your app level build.gradle:

plugins{
  id "com.google.protobuf" version "0.9.3"
}
android{...}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.23.4"
    }
    generateProtoTasks {
        all().forEach { task ->
            task.builtins {
                register("java") {
                    option("lite")
                }
                register("kotlin") {
                    option("lite")
                }
            }
        }
    }
}
dependencies{
    implementation "androidx.datastore:datastore:1.0.0"
    implementation  "com.google.protobuf:protobuf-kotlin-lite:3.23.4"
}

This should build successfully. If you are facing any issues, check this or just leave a comment.

Implementation:

  1. First, we need to define a proto scheme for our preferences object that we will store.

If required, you can define multiple scheme and store multiple preferences objects, but then you have you define datastore reference for each file. It’s recommended to store your pref in one single object.

Create a new file called user_prefs.proto in the app/src/main/proto directory.

syntax = "proto3";

option java_package = "your.package.name";
option java_multiple_files = true;

message UserPreferences {
  bool onboardingShown = 1;
}

2. Build your project to get an instance of the UserPreferences object.

3. Create a Serializer for our object for read, and write operations.

class UserPreferencesSerializer @Inject constructor() : Serializer<UserPreferences> {
    override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): UserPreferences =
        try {
            // readFrom is already called on the data store background thread
            UserPreferences.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }

    override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
        // writeTo is already called on the data store background thread
        t.writeTo(output)
    }
}

4. Create your Datastore instance in your Hilt module.

@Module
@InstallIn(SingletonComponent::class)
object AppModule {

    @Provides
    @Singleton
    fun providesUserPreferencesDataStore(
        @ApplicationContext context: Context,
        @IODispatcher ioDispatcher: CoroutineDispatcher,
        @ApplicationScope scope: CoroutineScope,
        userPreferencesSerializer: UserPreferencesSerializer,
    ): DataStore<UserPreferences> =
        DataStoreFactory.create(
            serializer = userPreferencesSerializer,
            scope = CoroutineScope(scope.coroutineContext + ioDispatcher),
        ) {
            context.dataStoreFile("user_preferences.pb")
        }

    @Provides
    @IODispatcher
    fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO

    @Provides
    @DefaultDispatcher
    fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default

    @Provides
    @Singleton
    @ApplicationScope
    fun providesCoroutineScope(
        @DefaultDispatcher dispatcher: CoroutineDispatcher,
    ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)

}

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IODispatcher

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultDispatcher

@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class ApplicationScope

I am injecting the Dispatchers using the annotations, you can do it your own way. These practices are from Now in Android.
5. Now you got the instance of Datastore but how we can use it? We will create a PreferenceManager class which will interact with our DataStore operations.

@Singleton
class PrefManager
@Inject constructor(
    private val userPreferences: DataStore<UserPreferences>
) {

    val userData : Flow<UserPreferences> = userPreferences.data //All your pref will be emitted here

    suspend fun setOnboardingShown(){//edit your preferences with different methods
        try{
            userPreferences.updateData {
                it.copy {
                    this.onboardingShown = true
                }
            }
        } catch (ioException: IOException) {
            Log.e("NiaPreferences", "Failed to update user preferences", ioException)
        }
    }
}

You can store any object in your proto datastore.

Now, you can inject only this PrefManager class in any of your repositories to interact with Datastore and perform any operation.

6. Have the same flow object in your Repository and then expose that flow in your ViewModel.

7. Use the stateIn() method to convert your repo exposed Flow object into a StateFlow of any preference you want.

In VM,

val onboardingShown: StateFlow<Boolean> =
        repo.userPreferences
            .map { it.onboardingShown }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = false,
            )

8. Now, I expect you to know how you can use this StateFlow in your compose using collectAsStateWithLifecycle().

Leave a comment if you are facing any issues or any improvements

I hope you found this helpful. If yes, then do FOLLOW ‘Sagar Malhotra’ for more Android-related content.

#androidWithSagar #android #androiddevelopment #development #compose #kotlin #proandroiddev #google #androiddev