Kotlin / SharedPreferences

Kotlin: Contexts & SharedPreferences

In advance of a new series of posts which start next week, I ported the code from a previous series of articles to Kotlin. I opted to port the code manually rather than rely on any automated conversions and there are a couple of things worthy of explanation hence this article.

Before we begin, I should point out that this isn’t going to be a full explanation of how all of the code works. Please refer to the original series for a full explanation of the code.

The first area worthy of discussion is where we obtain a Context which will store data to device encrypted storage, permitting our app to access it before the user has logged in to the device. To do this in Kotlin we can use an extension function:

fun Context.safeContext(): Context =
        takeUnless { isDeviceProtectedStorage }?.run {
            it.applicationContext.let {
                ContextCompat.createDeviceProtectedStorageContext(it) ?: it
            }
        } ?: this

takeUnless will only execute the run block if the predicate (in this case isDeviceProtectedStorage) evaluates to false. So we avoid repeating things if we already have the required device protected storage Context.

ContextCompat.createDeviceProtectedStorageContext() will return null on devices running Android lower than Nougat, so we use the elvis operator to ensure that we always return a valid Context – in this case it will be an application Context.

There’s nothing amazing happening here, but it gives us a really lightweight call to obtain a device protected storage Context without repeating the createDeviceProtectedStorageContext() call if we already have one.

The next trick is concerning SharedPreferences. A number of people have written about using Kotlin property delegates to really simplify accessing data which via SharedPreferences, so I claim absolutely no originality in the fundamental concept. However, I was not completely happy with any of the implementations that I found. Some required different functions to be called depending on the type of data being persisted to SharedPreferences (i.e. separate functions for Boolean, Int, Long, Float, and String values). Those which did not have this limitation generally determined the data type in the getValue() and setValue() functions of the ReadWriteProperty implementation.

I had a feeling that Kotlin had the capabilities to overcome these limitations, and came up with this approach:

private class SharedPreferenceDelegate(
        private val context: Context,
        private val defaultValue: T,
        private val getter: SharedPreferences.(String, T) -> T,
        private val setter: Editor.(String, T) -> Editor,
        private val key: String
) : ReadWriteProperty<Any, T> {

    private val safeContext: Context by lazyFast { context.safeContext() }

    private val sharedPreferences: SharedPreferences by lazyFast {
        PreferenceManager.getDefaultSharedPreferences(safeContext)
    }

    override fun getValue(thisRef: Any, property: KProperty<*>) =
            sharedPreferences
                    .getter(key, defaultValue)

    override fun setValue(thisRef: Any, property: KProperty<*>, value: T) =
            sharedPreferences
                    .edit()
                    .setter(key, value)
                    .apply()
}

@Suppress("UNCHECKED_CAST")
fun <T> bindSharedPreference(context: Context, key: String, defaultValue: T): ReadWriteProperty<Any, T> =
        when (defaultValue) {
            is Boolean ->
                SharedPreferenceDelegate(context, defaultValue, SharedPreferences::getBoolean, Editor::putBoolean, key)
            is Int ->
                SharedPreferenceDelegate(context, defaultValue, SharedPreferences::getInt, Editor::putInt, key)
            is Long ->
                SharedPreferenceDelegate(context, defaultValue, SharedPreferences::getLong, Editor::putLong, key)
            is Float ->
                SharedPreferenceDelegate(context, defaultValue, SharedPreferences::getFloat, Editor::putFloat, key)
            is String ->
                SharedPreferenceDelegate(context, defaultValue, SharedPreferences::getString, Editor::putString, key)
            else -> throw IllegalArgumentException()
        } as ReadWriteProperty<Any, T>

The bindSharedPreference() function is the only externally visible function. It is here that we perform the type checking and pass in function references for the getter and setter according to the type that is determined from the default value.

SharedPreferenceDelegate is a private class which implements the delegate. The getValue() and setValue() functions call the getter and setter that are specified in the constructor. The actual SharedPreferences instance is created lazily because the delegate may be created before the Context is valid. Provided the value is not accessed or changed are not called until we have a valid Context then everything works nicely.

SO what does this actually give us? Here’s an example of how we can use this:

private var notificationId: Int by bindSharedPreference(context, KEY_NOTIFICATION_ID, 0)

fun sendBundledNotification(message: Message) =
        with(notificationManager) {
            notify(notificationId++, buildNotification(message))
            notify(SUMMARY_ID, buildSummary(message))
        }

When we declare notificationId we delegate to the SharedPreferenceDelegate instance that is returned by bindSharedPreference(). We can now access it and modify its value just like we can with any Kotlin var but, because of the delegation, it will be automatically persisted from or to SharedPreferences. Using the increment operator (++) will read the value from SharedPreferences, increment it, and then save it again.

Property delegation is a really useful and powerful thing, but we need to recognise where it is being used. For example: Reading a value from SharedPreferences is far more expensive than reading a value stored in memory. When a team is working on a common codebase it would be easy for someone inexperienced in Kotlin to misuse a delegated property without checking how expensive accessing or changing it might be. There are ways that we can mitigate this, and in a future article we’ll explore some strategies for this.

The source code for this article is available here.

© 2017, Mark Allison. All rights reserved.

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

9 Comments

  1. I believe there is a problem in your first snippet :
    `apply` will return the object on which it is called and not the last line of the lambda ( source : https://github.com/JetBrains/kotlin/blob/1.1.3/libraries/stdlib/src/kotlin/util/Standard.kt#L49 )
    and therefore your snippet always returns the Context object on which you are calling it.

    I believe the right code should be :
    fun Context.safeContext(): Context =
    applicationContext.takeUnless { isDeviceProtectedStorage }?.let {
    ContextCompat.createDeviceProtectedStorageContext(it)
    } ?: this

    Also, I have removed the `?: it` after the `ContextCompat.createDeviceProtectedStorageContext(it)` as it will return null and this will be handled by the last Elvis operator.

    1. You are correct about the apply block, and I have change this appropriately, but the inner elvis operator is still required. If the call to ContextCompat.createDeviceProtectedStorageContext(it) returns null then the inner elvis operator ensures that we return an Application Context.

      The outer elvis operator is only utilised if we already have a Device Protected Storage context, so this extension function will return either a Device Protected Storage Context, or an Application Context, but never an Activity or Service Context. If we remove the inner elvis operator, that behaviour is no longer guaranteed.

      1. Thanks for the clarification, I didn’t understand it was important for you to return an applicationContext for the second elvis operator.
        Great article, thanks 🙂

  2. *EDIT*

    I made a mistake in my previous code, I didn’t fully understand the `takeUnless` so the code should be :
    fun Context.safeContext(): Context =
    takeUnless { isDeviceProtectedStorage }?.let {
    it.applicationContext.let {
    ContextCompat.createDeviceProtectedStorageContext(it)
    }
    } ?: this

  3. I’ve spotted one typo. In SharedPreferenceDelegate class declaration key is defined as the second argument but in bindSharedPreference method class constructor is always called with the key passed as the very last argument.

    But anyway great article.

  4. Great article!
    Though, personally, I prefer the compile-time safety of separate methods over the runtime error of an `IllegalArgumentException`. Slightly more code, but it makes it easier to spot a small mistake.

    fun bindSharedPreference(context: Context, key: String, defaultValue: Boolean): ReadWriteProperty =
    SharedPreferenceDelegate(context, defaultValue, SharedPreferences::getBoolean, Editor::putBoolean, defaultValue)

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.