DataStore / Jetpack

DataStore: 1.0.0-alpha08

Back in September 2020, I published a series of articles about the shiny new DataStore Jetpack library. DataStore impressed me at the time. However, there have been some changes since the 1.0.0-alpha01 release that we looked at back then. At the time of writing the latest version is 1.0.0-alpha08 and there have been some breaking changes since alpha01. In this article, we’ll look at those changes and bring the sample code up to date.

I have generally updated the project to use the latest versions of all the dependent libraries. Most of the updates were simply a case of updating the library version, but there were a couple of other dependencies which broke things.

Firstly there are some breaking changes to Hilt. Previously, the DataStoreModule was installed into the ApplicationComponent. However, ApplicationComponent has been deprecated in favour of SingletonComponent. This is more consistent with Dagger’s scope names.

The other breaking change was in Wire. The ComplexData protobuf had members named internal and external. These were instances of the enums Internal and External respectively. The newer implementation mapped these field names to internal_ and external_ to avoid potential clashes with the type itself. So I renamed these to internalEnum and externalEnum instead of using the training underscores.

Bug Fix

Before moving on to the changes required for DataStore, I must confess to a bug in the original sample code. When adding the DataStore encryption, I inadvertently included a bug that limits the encrypted data to 255 bytes. As part of the encrypted stream, I included the size of the encrypted data. This was written as an Integer. So it would overflow if the length was greater than 255 bytes. This would result in crashes when reading the data back as the size was incorrect.

To fix this I removed the size of the encrypted data altogether, and when reading it back we just read all of the remaining data:

interface Crypto {
    fun encrypt(rawBytes: ByteArray, outputStream: OutputStream)
    fun decrypt(inputStream: InputStream): ByteArray
}

class CryptoImpl @Inject constructor(private val cipherProvider: CipherProvider) : Crypto {

    override fun encrypt(rawBytes: ByteArray, outputStream: OutputStream) {
        val cipher = cipherProvider.encryptCipher
        val encryptedBytes = cipher.doFinal(rawBytes)
        with(outputStream) {
            write(cipher.iv.size)
            write(cipher.iv)
            write(encryptedBytes)
        }
    }

    override fun decrypt(inputStream: InputStream): ByteArray {
        val ivSize = inputStream.read()
        val iv = ByteArray(ivSize)
        inputStream.read(iv)
        val encryptedData = inputStream.readBytes()
        val cipher = cipherProvider.decryptCipher(iv)
        return cipher.doFinal(encryptedData)
    }
}

The size of the Initialisation Vector will typically be small – 16 bytes – so we don’t need to worry about storing its size as an Integer.

DataStore Changes

There have been a number of changes to DataStore which broke the code. Jetpack libraries may not have stable APIs at alpha release. So one of the problems of using them is that you may have to update your code if the APIs change.

The first change which affected the sample code from the previous series is that the core DataStore library has been split in to two separate libraries. datastore-core contains the pure Kotlin components that have no dependencies on the Android Framework. datastore is much smaller but provides integrations with the Android Framework. So it was necessary to change the library dependency to use datastore instead of datastore-core.

This refactoring into separate libraries, also included some package changes for some of the DataStore components. These needed changing accordingly throughout the sample code.

The most significant change is how we construct instances of our DataStore. Previously there was an extension function of Context named createDataStore(). A property delegate that returns a global data store instance has replaced this. The idea here is that we use the delegate to obtain an instance of our global DataStore instance in the consumer:

val Context.myDataStore by dataStore(...)

The intention here is good, but I’m not sure I agree with the implementation. The intent is that by using this pattern, we’ll use a singleton throughout the app. However, this becomes problematic when we’re doing things that are a little more complex than basic use-cases.

The problem with the property delegate

Hilt injects a Singleton DataStore instance wherever it is needed in the sample code. The module which provides it looks like this in the old sample code:

@Module
@InstallIn(ApplicationComponent::class)
object DataStoreModule {

    @Provides
    fun providesComplexDataStore(
        @ApplicationContext context: Context,
        crypto: Crypto
    ): DataStore =
        context.createDataStore(
            fileName = "ComplexDataStoreTest.pb",
            serializer = ComplexDataSerializer(crypto)
        )
}

We cannot use the property delegate inside the provides function. It is not a property if it is declared inside a function. So it needs to be declared as a property of the DataStoreModule object. However, the DataStoreModule cannot be injected with the required Crypto instance. It is part of the DI framework, not part of our application code.

This rules out using the property delegate in our DI modules if we have other dependencies which are required for the DataStore. In this case the Serializer instance needs a Crypto instance.

One option would be to inject the Crypto or Serializer instance in to the consumer, and use the property delegate there:

// This will not work
@AndroidEntryPoint
class MyFragment : Fragment(R.layout_my_fragment) {
    @Inject
    lateinit var serializer: Serializer

    private val Context.dataStore: DataStore by dataStore(
        fileName = "ComplexDataDataStore.pb",
        serializer = serializer
    )
}

However, as the comment suggests, this will not work because serializer will not have been initialised by the time the property delegate is invoked to obtain the data store instance.

The solution that I decided upon was to avoid using the property delegate altogether, and use DataStore.Factory instead:

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

    @Provides
    fun providesComplexDataStore(
        @ApplicationContext context: Context,
        crypto: Crypto
    ): DataStore = DataStoreFactory.create(
        produceFile = { context.dataStoreFile("ComplexDataDataStore.pb") },
        serializer = ComplexDataSerializer(crypto)
    )
}

This works, but it is now the responsibility of the DI components to ensure that a singleton is used. That is achieved by the @InstallIn(SingletonComponent::class) annotation.

The property delegate ensures the use of a singleton instance. But the choice of implementing this as a property delegate means that it can be impossible to use it in more complex scenarios which benefit most from Inversion Of Control provided by Dependency Injection.

Testability

The use of a property delegate also makes testing much harder. Mocking things that we don’t own is generally considered bad practice for testing. While we don’t own the DataStore instance, we do own the data that it produces and consumes. (It’s actually a bit more subtle than that. We sort of own the DataStore because it is generated code based off of our protobuf definition). So being able to substitute in an alternative DataStore instance can make testing much easier. However, the use of a property delegate makes this impossible. So for testability, we need to avoid using the property delegate. Once again, creating the DataStore instance in the Hilt module makes testing much easier.

viewModels() property delegate

Those familiar with the KTX viewModels() implementation will know that this is actually a property delegate. While I think that the choice of a property delegate for DataStore has been inspired by this, there are some significant differences in implementation.

Firstly, ViewModel is a first class citizen when it comes to Hilt. The @HiltViewModel annotation adds the ViewModel to the dependency graph. The viewModels() property delegate will provide injected instances from the Hilt dependency graph. By contrast, DataStore currently has no integration whatsoever with Hilt.

Secondly, the viewModels() property delegate has an optional factoryProducer argument. The factoryProducer is responsible for creating ViewModel instances. So we can substitute in alternative ViewModel implementations using this mechanism. This is used as part of the Hilt integration, but also allows us to use fakes or mocks for testing. Once again, the DataStore property delegate is currently hard-coded to create the DataStore instance so doesn’t offer the same flexibility.

Thirdly, the viewModels() property delegate is also a Lazy delegate. This means that it will not attempt to instantiate the ViewModel instance until it is accessed. This plays much nicer within Android lifecycles because creation is performed much later on. Conversely, the DataStore property delegate is invoked when its parent class is created. This will be very early on in components with a lifecycle, and may be before injection has occurred.

Conclusion

On the whole, DataStore is improving. However, I feel that the current implementation of the property delegate is flawed because it simply will not play nicely with DI nor allow for easy testing. Having to manually enforce a singleton in the DI modules can easily be missed.

The source code for this article is available here.

Many thanks to Sebastiano Poggi for proofreading and technical feedback.

© 2021, Mark Allison. All rights reserved.

Copyright © 2021 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.