Last active
May 2, 2020 11:08
-
-
Save jaredrummler/549d4b938c8d352b8e88abbb4986e380 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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