Skip to content

Instantly share code, notes, and snippets.

@jaredrummler
Last active May 2, 2020 11:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jaredrummler/549d4b938c8d352b8e88abbb4986e380 to your computer and use it in GitHub Desktop.
Save jaredrummler/549d4b938c8d352b8e88abbb4986e380 to your computer and use it in GitHub Desktop.
/*
* Copyright (C) 2020 Jared Rummler
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.goatbytes.android.prefs
import android.content.Context
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import androidx.preference.PreferenceManager
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
/**
* Prefs used for accessing and modifying preference data in a clean, simple and safe way.
*
* Example usage:
*
* ```
* val prefs = Prefs(context)
* val age: Int = prefs["age"]
* prefs.edit {
* this["age"] = age + 1
* }
* ```
*
* @param preferences The backing preferences.
* @param encryptor The encryptor used to encrypt/decrypt strings written to preferences.
*/
class Prefs private constructor(
val preferences: SharedPreferences,
val encryptor: Encryptor = Encryptor.NONE
) {
/**
* Construct a new Prefs object with the default shared preferences.
*
* @param context The context of the preferences whose values are wanted.
* @param encryptor The [Encryptor] used to encrypt and decrypt Strings written to preferences.
*/
constructor(
context: Context, encryptor: Encryptor = Encryptor.NONE
) : this(PreferenceManager.getDefaultSharedPreferences(context), encryptor)
/**
* Construct a new Prefs object with the desired preference file name.
*
* @param name Desired preferences file. If a preferences file by this name does not exist, it
* will be created when you retrieve an editor.
* @param context The context of the preferences whose values are wanted.
* @param encryptor The [Encryptor] used to encrypt and decrypt Strings written to preferences.
*/
constructor(
name: String, context: Context, encryptor: Encryptor = Encryptor.NONE
) : this(context.getSharedPreferences(name, Context.MODE_PRIVATE), encryptor)
/**
* Retrieve all values from the preferences.
*
* <p>Note that you <em>must not</em> modify the collection returned
* by this method, or alter any of its contents. The consistency of your
* stored data is not guaranteed if you do.
*
* @return Returns a map containing a list of pairs key/value representing
* the preferences.
*
* @throws NullPointerException
*/
val all: Map<String, *> get() = preferences.all
/**
* Checks whether the preferences contains a preference.
*
* @param key The name of the preference to check.
* @return Returns true if the preference exists in the preferences, otherwise false.
*/
fun contains(key: String): Boolean = preferences.contains(key.obfuscated)
/**
*
* @param key The name of the preference to retrieve.
* @return The preference value if it exists, or null.
* @throws IllegalStateException if the preference with the given name does not exist.
*/
inline fun <reified T : Any> require(key: String): T =
this[key] ?: throw IllegalStateException("Preference with name $key does not exist")
/**
* Retrieve a value from the preferences.
*
* @param key The name of the preference to retrieve.
* @param defValue Value to return if this preference does not exist.
* @return The preference value if it exists, or null.
*/
inline operator fun <reified T : Any> get(key: String, defValue: T): T = this[key] ?: defValue
/**
* Retrieve a value from the preferences.
*
* @param name The name of the preference to retrieve.
* @return The preference value if it exists, or null.
*/
inline operator fun <reified T : Any> get(name: String): T? = name.obfuscated.let { key ->
if (contains(key)) {
when (val value = all[key]) {
is String -> if (T::class == String::class) {
value.decrypt as T
} else try {
Gson().fromJson(value.decrypt, T::class.java)
} catch (e: JsonSyntaxException) {
null
}
value is Set<*> && value.all { it is String } -> {
mutableSetOf<String>().apply {
@Suppress("UNCHECKED_CAST")
(value as Set<String>).forEach { plainText ->
plainText.decrypt?.let { decrypted -> add(decrypted) }
}
}.toSet() as T
}
else -> value as? T
}
} else null
}
/**
* This atomically performs the requested modifications, replacing whatever is currently in the SharedPreferences.
*
* @param name The name of the preference to modify.
* @param value The new value for the preference.
*/
inline operator fun <reified T : Any> set(name: String, value: T?) =
edit().put(name, value).apply()
/**
* Create a new [Editor] for these preferences, through which you can make modifications to the
* data in the preferences and atomically commit those changes back to the SharedPreferences
* object.
*
* Note that you <em>must</em> call [Editor.commit] to have any changes you perform in the
* Editor actually show up in the SharedPreferences.
*
* @return Returns a new instance of the [Editor] interface, allowing you to modify the values
* in this SharedPreferences object.
*/
fun edit() = Editor(this)
/**
* Allows editing of this preference instance with a call to [apply][Editor.apply].
*
* ```
* prefs.edit {
* this["key"] = value
* }
* ```
*
* @param action The action to perform on the editor.
*/
fun edit(action: Editor.() -> Unit) = edit().apply(action).apply()
/**
* Class used for modifying values in [Prefs] object. All changes you make in an editor are
* batched, and not copied back to the original {@link SharedPreferences} until you call
* [commit] or [apply].
*/
class Editor(private val prefs: Prefs) {
private val editor: SharedPreferences.Editor by lazy { prefs.preferences.edit() }
/**
* Set a value in the preferences editor, to be written back once [commit] or [apply] are called.
*
* @param key The name of the preference to modify.
* @param value The new value for the preference. Passing `null` for this argument is
* equivalent to calling [remove] with this key.
*
* @return Returns a reference to the same Editor object, so you can chain put calls together.
*/
fun <T : Any> put(name: String, value: T?) = apply {
editor.apply edit@{
prefs.encryptor.obfuscate(name).let { key ->
when (value) {
null -> remove(key)
is String -> putString(key, value.encrypted)
is Float -> putFloat(key, value)
is Int -> putInt(key, value)
is Long -> putLong(key, value)
is Boolean -> putBoolean(key, value)
else -> value.ifSetOf<String>({ stringSet ->
putStringSet(key, stringSet.encrypt())
}, {
Gson().toJson(value).encrypted
})
}
}
}
}
/**
* Set a value in the preferences editor, to be written back once [commit] or [apply] are called.
*
* @param key The name of the preference to modify.
* @param value The new value for the preference. Passing `null` for this argument is
* equivalent to calling [remove] with this key.
*
* @return Returns a reference to the same Editor object, so you can chain put calls together.
*/
operator fun <T : Any> set(name: String, value: T?) = put(name, value)
/**
* Mark in the editor that a preference value should be removed, which will be done in the
* actual preferences once [commit] or [apply] is called.
*
* <p>Note that when committing back to the preferences, all removals are done first,
* regardless of whether you called remove before or after put methods on this editor.
*
* @param name The name of the preference to remove.
*
* @return Returns a reference to the same Editor object, so you can chain put calls together.
*/
fun remove(name: String) = apply { editor.remove(prefs.encryptor.obfuscate(name)) }
/**
* Mark in the editor to remove `all` values from the preferences. Once commit is called,
* the only remaining preferences will be any that you have defined in this editor.
*
* Note that when committing back to the preferences, the clear is done first, regardless
* of whether you called clear before or after put methods on this editor.
*
* @return Returns a reference to the same Editor object, so you can chain put calls together.
*/
fun clear() = apply { editor.clear() }
/**
* Commit your preferences changes back from this Editor to the [SharedPreferences] object
* it is editing. This atomically performs the requested modifications, replacing whatever
* is currently in the SharedPreferences.
*
* Unlike [commit], which writes its preferences out to persistent storage synchronously,
* [apply] commits its changes to the in-memory [SharedPreferences] immediately but starts
* an asynchronous commit to disk and you won't be notified of any failures. If another
* editor on this [SharedPreferences] does a regular [commit] while a [apply] is still
* outstanding, the [commit] will block until all async commits are completed as well as
* the commit itself.
*
* As [SharedPreferences] instances are singletons within a process, it's safe to replace
* any instance of [commit] with [apply] if you were already ignoring the return value.
*
* You don't need to worry about Android component lifecycles and their interaction with
* `apply()` writing to disk. The framework makes sure in-flight disk writes from `apply`
* complete before switching states.
*/
fun apply() = editor.apply()
/**
* Commit your preferences changes back from this Editor to the [SharedPreferences] object
* it is editing. This atomically performs the requested modifications, replacing whatever
* is currently in the SharedPreferences.
*
* Note that when two editors are modifying preferences at the same time, the last one to
* call commit wins.
*
* If you don't care about the return value and you're using this from your application's
* main thread, consider using [apply] instead.
*
* @return Returns true if the new values were successfully written to persistent storage.
*/
fun commit() = editor.commit()
val String?.encrypted: String? get() = prefs.encryptor.encrypt(this)
private inline fun <reified T : Any> Any?.ifSetOf(
runnable: (set: Set<T>) -> Unit, otherwise: () -> Unit = {}
) = if (this is Set<*> && all { it is T }) {
@Suppress("UNCHECKED_CAST")
runnable(this as Set<T>)
} else {
otherwise()
}
private fun Set<String>.encrypt(): Set<String> = mutableSetOf<String>().also { set ->
forEach { plainText ->
set.add(prefs.encryptor.encrypt(plainText)!!)
}
}.toSet()
}
/**
* Toggle a boolean value from true to false or vice versa.
*
* @param name The name of the boolean preference to toggle.
* @param defValue The default value for the preference, set if this preference does not exist.
*/
fun toggle(name: String, defValue: Boolean) = edit {
if (contains(name)) {
this[name] = !require<Boolean>(name)
} else {
this[name] = defValue
}
}
/**
* Increases the value of an int preference.
*
* @param name The name of the preference to modify.
* @param increment The amount by which the preference should be increased.
* @return The value after incrementing the preference.
*/
fun increment(name: String, increment: Int = 1): Int {
val value = (this[name] ?: 0) + increment
edit { this[name] = value }
return value
}
/**
* Clears all values from the preferences.
*/
fun clearAll() {
preferences.edit().clear().apply()
}
/**
* Registers a callback to be invoked when a change happens to a preference.
*
* **Caution:** The preference manager does not currently store a strong reference to the
* listener. You must store a strong reference to the listener, or it will be susceptible to
* garbage collection. We recommend you keep a reference to the listener in the instance data
* of an object that will exist as long as you need the listener.
*
* @param listener The callback that will run.
* @see unregisterOnSharedPreferenceChangeListener
*/
fun registerOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) =
preferences.registerOnSharedPreferenceChangeListener(listener)
/**
* Unregisters a previous callback.
*
* @param listener The callback that should be unregistered.
* @see registerOnSharedPreferenceChangeListener
*/
fun unregisterOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) =
preferences.unregisterOnSharedPreferenceChangeListener(listener)
val String.obfuscated: String get() = encryptor.obfuscate(this)
val String?.decrypt: String? get() = encryptor.decrypt(this)
/**
* Interface used to encrypt and decrypt Strings saved to [Prefs] and obfuscate preference names
*/
interface Encryptor {
/**
* Encrypt a String.
*
* @param value the String to encrypt.
* @return The encrypted String.
*/
fun encrypt(value: String?): String?
/**
* Decrypt a String.
*
* @param encrypted The encrypted text using [decrypt].
* @return The original String.
*/
fun decrypt(encrypted: String?): String?
/**
* Obfuscate a preference name. The default implementation is no obfuscation.
*
* @param key the original preference name.
* @return an obfuscated key using the preference name.
*/
fun obfuscate(key: String): String = key
/** An [Encryptor] that does nothing. */
object NONE : Encryptor {
override fun encrypt(value: String?): String? = value
override fun decrypt(encrypted: String?): String? = encrypted
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment