Skip to content

Instantly share code, notes, and snippets.

@kibotu
Created May 13, 2025 06:47
Show Gist options
  • Save kibotu/d4ca97924f18226b42cdb387492fca67 to your computer and use it in GitHub Desktop.
Save kibotu/d4ca97924f18226b42cdb387492fca67 to your computer and use it in GitHub Desktop.
Advanced android kotlin shared preference delegate.
import android.content.SharedPreferences
import androidx.core.content.edit
import de.check24.android.plugin.profis.events.shared.logger.EventsLogger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
/**
* A type-safe SharedPreferences delegate with enhanced features:
* - Thread-safe operations
* - Reactive updates via Flow
* - Memory caching (optional)
* - Custom serialization/deserialization support
* - Migration capabilities
* - Null value support
* - Automatic commit/apply options
*
* @param T The type of the property value
*/
class SharedPreferenceDelegate<T> private constructor(
private val sharedPreferences: () -> SharedPreferences?,
private val key: String,
private val defaultValue: T,
private val isCachingEnabled: Boolean,
private val useCommit: Boolean,
private val serializer: ((T) -> String)?,
private val deserializer: ((String) -> T)?,
private val migrationStrategy: ((SharedPreferences, String) -> Unit)?
) : ReadWriteProperty<Any, T> {
private var cachedValue: T? = null
private val valueFlow = MutableStateFlow(defaultValue)
private val accessLock = Any()
/**
* Get the property value, either from cache or SharedPreferences.
*/
@Suppress("UNCHECKED_CAST")
override fun getValue(thisRef: Any, property: KProperty<*>): T = synchronized(accessLock) {
if (isCachingEnabled && cachedValue != null) {
return cachedValue as T
}
val preferences = sharedPreferences()
if (preferences == null) {
EventsLogger.w("SharedPreferences is null - returning default value for key: $key")
return defaultValue
}
// Apply any migrations if needed
migrationStrategy?.invoke(preferences, key)
// Use custom deserializer for complex types if provided
val result = when {
deserializer != null -> {
if (preferences.contains(key)) {
val serializedValue = preferences.getString(key, null)
if (serializedValue != null) deserializer.invoke(serializedValue) else defaultValue
} else {
defaultValue
}
}
// Handle nullable types with special marker
defaultValue == null -> {
if (preferences.contains("${key}_is_null") && preferences.getBoolean("${key}_is_null", false)) {
null as T
} else {
readFromPreferences(preferences, key, null) ?: defaultValue
}
}
// Standard primitive types
else -> readFromPreferences(preferences, key, defaultValue) ?: defaultValue
}
if (isCachingEnabled) {
cachedValue = result
}
valueFlow.value = result
return result
}
/**
* Set the property value in SharedPreferences.
*/
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) = synchronized(accessLock) {
val preferences = sharedPreferences()
if (preferences == null) {
EventsLogger.w("SharedPreferences is null - can't save value for key: $key")
return
}
// If the value is the same as cached, avoid unnecessary write
if (isCachingEnabled && cachedValue == value) {
return
}
preferences.edit(commit = useCommit) {
when {
// Use custom serializer for complex types if provided
serializer != null -> {
val serialized = serializer.invoke(value)
putString(key, serialized)
}
// Handle null values with a special marker
value == null -> {
putBoolean("${key}_is_null", true)
remove(key)
}
// Standard primitive types
else -> writeToPreferences(this, key, value)
}
}
cachedValue = value
valueFlow.value = value
}
/**
* Observe changes to this preference value as a Flow.
* @return A Flow that emits the current value and subsequent updates
*/
fun asFlow(): Flow<T> = valueFlow
/**
* Invalidate the current cached value, forcing the next read to fetch from SharedPreferences.
*/
fun invalidateCache() = synchronized(accessLock) {
cachedValue = null
}
/**
* Delete this preference from SharedPreferences.
*/
fun delete() = synchronized(accessLock) {
val preferences = sharedPreferences() ?: return
preferences.edit(commit = useCommit) {
remove(key)
remove("${key}_is_null")
}
cachedValue = null
valueFlow.value = defaultValue
}
@Suppress("UNCHECKED_CAST")
private fun readFromPreferences(preferences: SharedPreferences, key: String, defaultValue: T?): T? {
if (!preferences.contains(key)) return null
return when (defaultValue) {
is Boolean?, is Boolean -> preferences.getBoolean(key, (defaultValue as? Boolean) ?: false) as T?
is Int?, is Int -> preferences.getInt(key, (defaultValue as? Int) ?: 0) as T?
is Long?, is Long -> preferences.getLong(key, (defaultValue as? Long) ?: 0L) as T?
is Float?, is Float -> preferences.getFloat(key, (defaultValue as? Float) ?: 0f) as T?
is String?, is String -> preferences.getString(key, defaultValue as? String) as T?
is Set<*>?, is Set<*> -> preferences.getStringSet(key, defaultValue as? Set<String>) as T?
else -> {
if (defaultValue == null) null
else throw IllegalArgumentException("Type not supported: ${defaultValue::class.java.name}")
}
}
}
private fun writeToPreferences(editor: SharedPreferences.Editor, key: String, value: T) {
when (value) {
is Boolean -> editor.putBoolean(key, value)
is Int -> editor.putInt(key, value)
is Long -> editor.putLong(key, value)
is Float -> editor.putFloat(key, value)
is String -> editor.putString(key, value)
is Set<*> -> @Suppress("UNCHECKED_CAST") editor.putStringSet(key, value as Set<String>)
else -> throw IllegalArgumentException("Type not supported: ${value::class.java.name}")
}
}
/**
* Builder for creating SharedPreferenceDelegate instances.
*/
class Builder<T>(
private val sharedPreferences: () -> SharedPreferences?,
private val defaultValue: T
) {
private var key: String? = null
private var isCachingEnabled: Boolean = true
private var useCommit: Boolean = false
private var serializer: ((T) -> String)? = null
private var deserializer: ((String) -> T)? = null
private var migrationStrategy: ((SharedPreferences, String) -> Unit)? = null
/**
* Set a custom key for the preference.
* @param key Custom key to use instead of property name
*/
fun key(key: String) = apply { this.key = key }
/**
* Enable or disable in-memory caching.
* @param enabled Whether caching should be enabled
*/
fun withCaching(enabled: Boolean) = apply { this.isCachingEnabled = enabled }
/**
* Use commit() instead of apply() for immediate writes.
* @param useCommit Whether to use commit instead of apply
*/
fun useCommit(useCommit: Boolean) = apply { this.useCommit = useCommit }
/**
* Provide custom serialization for complex types.
* @param serializer Function to convert value to String
* @param deserializer Function to convert String back to value
*/
fun withCustomSerialization(
serializer: (T) -> String,
deserializer: (String) -> T
) = apply {
this.serializer = serializer
this.deserializer = deserializer
}
/**
* Provide a migration strategy for this preference.
* @param strategy Function to migrate from old format to new
*/
fun withMigration(strategy: (SharedPreferences, String) -> Unit) = apply {
this.migrationStrategy = strategy
}
/**
* Build the SharedPreferenceDelegate instance.
* @param propertyName Name of the property (used if key is not set)
*/
fun build(propertyName: String): SharedPreferenceDelegate<T> {
return SharedPreferenceDelegate(
sharedPreferences = sharedPreferences,
key = key ?: propertyName,
defaultValue = defaultValue,
isCachingEnabled = isCachingEnabled,
useCommit = useCommit,
serializer = serializer,
deserializer = deserializer,
migrationStrategy = migrationStrategy
)
}
}
companion object {
/**
* Create a builder for SharedPreferenceDelegate.
* @param sharedPreferences Lambda providing SharedPreferences instance
* @param defaultValue Default value if preference is not found
*/
fun <T> builder(
sharedPreferences: () -> SharedPreferences?,
defaultValue: T
): Builder<T> {
return Builder(sharedPreferences, defaultValue)
}
}
}
/**
* Create a type-safe [ReadWriteProperty] backed by SharedPreferences.
* @param defaultValue Default value if preference is not found
* @param key Optional custom key (defaults to property name)
* @param isCachingEnabled Whether in-memory caching should be enabled
* @param useCommit Whether to use commit() instead of apply()
* @param sharedPreferences Lambda providing the SharedPreferences instance
* @return A property delegate for the specified type
*/
fun <T> sharedPreference(
defaultValue: T,
key: String? = null,
isCachingEnabled: Boolean = true,
useCommit: Boolean = false,
sharedPreferences: () -> SharedPreferences?
): ReadWriteProperty<Any, T> {
return object : ReadWriteProperty<Any, T> {
private var delegate: SharedPreferenceDelegate<T>? = null
override fun getValue(thisRef: Any, property: KProperty<*>): T {
if (delegate == null) {
delegate = SharedPreferenceDelegate.builder(sharedPreferences, defaultValue)
.key(key ?: property.name)
.withCaching(isCachingEnabled)
.useCommit(useCommit)
.build(property.name)
}
return delegate!!.getValue(thisRef, property)
}
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
if (delegate == null) {
delegate = SharedPreferenceDelegate.builder(sharedPreferences, defaultValue)
.key(key ?: property.name)
.withCaching(isCachingEnabled)
.useCommit(useCommit)
.build(property.name)
}
delegate!!.setValue(thisRef, property, value)
}
}
}
/**
* Create a property delegate with custom serialization/deserialization.
* @param defaultValue Default value if preference is not found
* @param key Optional custom key (defaults to property name)
* @param serializer Function to convert value to String
* @param deserializer Function to convert String back to value
* @param isCachingEnabled Whether in-memory caching should be enabled
* @param sharedPreferences Lambda providing the SharedPreferences instance
* @return A property delegate for the specified complex type
*/
fun <T> serializedSharedPreference(
defaultValue: T,
key: String? = null,
serializer: (T) -> String,
deserializer: (String) -> T,
isCachingEnabled: Boolean = true,
useCommit: Boolean = false,
sharedPreferences: () -> SharedPreferences?
): ReadWriteProperty<Any, T> {
return object : ReadWriteProperty<Any, T> {
private var delegate: SharedPreferenceDelegate<T>? = null
override fun getValue(thisRef: Any, property: KProperty<*>): T {
if (delegate == null) {
delegate = SharedPreferenceDelegate.builder(sharedPreferences, defaultValue)
.key(key ?: property.name)
.withCaching(isCachingEnabled)
.useCommit(useCommit)
.withCustomSerialization(serializer, deserializer)
.build(property.name)
}
return delegate!!.getValue(thisRef, property)
}
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
if (delegate == null) {
delegate = SharedPreferenceDelegate.builder(sharedPreferences, defaultValue)
.key(key ?: property.name)
.withCaching(isCachingEnabled)
.useCommit(useCommit)
.withCustomSerialization(serializer, deserializer)
.build(property.name)
}
delegate!!.setValue(thisRef, property, value)
}
}
}
/**
* Usage example:
*
* class MyPreferences(private val context: Context) {
* private val prefs by lazy { context.getSharedPreferences("my_prefs", Context.MODE_PRIVATE) }
*
* // Basic usage
* var userId by sharedPreference(
* defaultValue = "",
* sharedPreferences = { prefs }
* )
*
* // Complex object with custom serialization
* var userProfile by serializedSharedPreference(
* defaultValue = UserProfile(),
* serializer = { Gson().toJson(it) },
* deserializer = { Gson().fromJson(it, UserProfile::class.java) },
* sharedPreferences = { prefs }
* )
*
* // Advanced usage with builder pattern
* private val _darkMode = SharedPreferenceDelegate.builder(
* sharedPreferences = { prefs },
* defaultValue = false
* )
* .key("theme_dark_mode")
* .withCaching(true)
* .useCommit(true)
* .withMigration { prefs, _ ->
* // Migrate from old key if needed
* if (prefs.contains("old_dark_mode_key") && !prefs.contains("theme_dark_mode")) {
* prefs.edit {
* putBoolean("theme_dark_mode", prefs.getBoolean("old_dark_mode_key", false))
* remove("old_dark_mode_key")
* }
* }
* }
* .build("darkMode")
*
* var darkMode by _darkMode
* val darkModeFlow = _darkMode.asFlow()
*
* fun resetDarkMode() {
* _darkMode.delete()
* }
* }
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment