Skip to content

Commit

Permalink
Add experimental change listener
Browse files Browse the repository at this point in the history
  • Loading branch information
russhwolf committed Nov 1, 2018
1 parent 7ea1730 commit b3c6f01
Show file tree
Hide file tree
Showing 20 changed files with 361 additions and 11 deletions.
2 changes: 1 addition & 1 deletion .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ Existence of a key can be queried
Finally, all values in a `Settings` instance can be removed

settings.clear()

## Experimental API

### Listeners

Update listeners are available using an experimental API.

val settingsListener: SettingsListener = settings.addListener(key) { ... }

The `SettingsListener` returned from the call should be used to signal when you're done listening:

settings.removeListener(settingsListener)

This initial listener implementation is not designed with any sort of thread-safety so it's recommended to only interact with these APIs from the main thread of your application.

The listener APIs make use of the Kotlin `@Experimental` annotation. All usages must be marked with `@ExperimentalListener` or `@UseExperimental(ExperimentalListener::class)`.

## Project Structure
The library logic lives in the `common`, `android`, and `ios` modules. The common module holds `expect` declarations for the `Settings` class, which can persist values of the `Int`, `Long`, `String`, `Float`, `Double`, and `Boolean` types. It also holds property delegate wrappers and other operator functions for cleaner syntax and usage. The android and ios modules then hold `actual` declarations, delegating to `SharedPreferences` or `NSUserDefaults`.
Expand Down
6 changes: 6 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,9 @@ dependencies {
testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
testImplementation "org.robolectric:robolectric:4.0"
}

tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) { task ->
kotlinOptions {
freeCompilerArgs = ['-Xuse-experimental=kotlin.Experimental']
}
}
67 changes: 64 additions & 3 deletions android/src/main/java/com/russhwolf/settings/PlatformSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
import android.preference.PreferenceManager
import com.russhwolf.settings.Settings.Listener

/**
* A collection of storage-backed key-value data
Expand Down Expand Up @@ -116,7 +117,8 @@ public actual class PlatformSettings public constructor(private val delegate: Sh
/**
* Stores the `String` [value] at [key].
*/
public actual override fun putString(key: String, value: String): Unit = delegate.edit().putString(key, value).apply()
public actual override fun putString(key: String, value: String): Unit =
delegate.edit().putString(key, value).apply()

/**
* Returns the `String` value stored at [key], or [defaultValue] if no value was stored. If a value of a different
Expand Down Expand Up @@ -152,11 +154,70 @@ public actual class PlatformSettings public constructor(private val delegate: Sh
/**
* Stores the `Boolean` [value] at [key].
*/
public actual override fun putBoolean(key: String, value: Boolean): Unit = delegate.edit().putBoolean(key, value).apply()
public actual override fun putBoolean(key: String, value: Boolean): Unit =
delegate.edit().putBoolean(key, value).apply()

/**
* Returns the `Boolean` value stored at [key], or [defaultValue] if no value was stored. If a value of a different
* type was stored at `key`, the behavior is not defined.
*/
public actual override fun getBoolean(key: String, defaultValue: Boolean): Boolean = delegate.getBoolean(key, defaultValue)
public actual override fun getBoolean(key: String, defaultValue: Boolean): Boolean =
delegate.getBoolean(key, defaultValue)

/**
* Adds a listener which will call the supplied [callback] anytime the value at [key] changes. A [Listener]
* reference is returned which should be passed to [removeListener] when you no longer need it so that the
* associated platform resources can be cleaned up.
*
* A strong reference should be held to the `Listener` returned by this method in order to avoid it being
* garbage-collected on Android.
*
* No attempt is made in the current implementation to safely handle multithreaded interaction with the listener, so
* it's recommended that interaction with the listener APIs be confined to the main UI thread.
*/
@ExperimentalListener
actual override fun addListener(key: String, callback: () -> Unit): Settings.Listener {
val cache = Listener.Cache(delegate.all[key])

val prefsListener =
SharedPreferences.OnSharedPreferenceChangeListener { _: SharedPreferences, updatedKey: String ->
if (updatedKey != key) return@OnSharedPreferenceChangeListener

/*
According to the OnSharedPreferenceChangeListener contract, we might get called for an update even
if the value didn't change. We hold a cache to ensure that the user-supplied callback only updates on
actual changes, in order to ensure that we match iOS behavior
*/
val prev = cache.value
val current = delegate.all[key]
if (prev != current) {
callback()
cache.value = current
}
}
delegate.registerOnSharedPreferenceChangeListener(prefsListener)
return Listener(prefsListener)
}

/**
* Unsubscribes the [listener] from receiving updates to the value at the key it monitors
*/
@ExperimentalListener
actual override fun removeListener(listener: Settings.Listener) {
val platformListener = listener as? Listener
val listenerDelegate = platformListener?.delegate
listenerDelegate?.let(delegate::unregisterOnSharedPreferenceChangeListener)
}

/**
* A handle to a listener instance created in [addListener] so it can be passed to [removeListener]
*
* On the Android platform, this is a wrapper around [SharedPreferences.OnSharedPreferenceChangeListener].
*/
@ExperimentalListener
actual class Listener internal constructor(
internal val delegate: SharedPreferences.OnSharedPreferenceChangeListener
) : Settings.Listener {
internal class Cache(var value: Any?)
}
}
5 changes: 5 additions & 0 deletions common/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,8 @@ dependencies {
testCompile "org.jetbrains.kotlin:kotlin-test-common:$kotlin_version"
}

tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompileCommon) { task ->
kotlinOptions {
freeCompilerArgs = ['-Xuse-experimental=kotlin.Experimental']
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright 2018 Russell Wolf
*
* 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 com.russhwolf.settings

/**
* Annotation to mark listener functionality as experimental.
*/
@Experimental
annotation class ExperimentalListener
11 changes: 11 additions & 0 deletions common/src/main/kotlin/com/russhwolf/settings/PlatformSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,16 @@ public expect class PlatformSettings : Settings {
public override fun getDouble(key: String, defaultValue: Double): Double
public override fun putBoolean(key: String, value: Boolean)
public override fun getBoolean(key: String, defaultValue: Boolean): Boolean

@ExperimentalListener
public override fun addListener(key: String, callback: () -> Unit): Settings.Listener
@ExperimentalListener
public override fun removeListener(listener: Settings.Listener)

/**
* A handle to a listener instance created in [addListener] so it can be passed to [removeListener]
*/
@ExperimentalListener
public class Listener : Settings.Listener
}

25 changes: 25 additions & 0 deletions common/src/main/kotlin/com/russhwolf/settings/Settings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,29 @@ public interface Settings {
*/
public fun getBoolean(key: String, defaultValue: Boolean = false): Boolean

/**
* Adds a listener which will call the supplied [callback] anytime the value at [key] changes. A [Listener]
* reference is returned which should be passed to [removeListener] when you no longer need it so that the
* associated platform resources can be cleaned up.
*
* A strong reference should be held to the `Listener` returned by this method in order to avoid it being
* garbage-collected on Android.
*
* No attempt is made in the current implementation to safely handle multithreaded interaction with the listener, so
* it's recommended that interaction with the listener APIs be confined to the main UI thread.
*/
@ExperimentalListener
public fun addListener(key: String, callback: () -> Unit) : Listener

/**
* Unsubscribes the [listener] from receiving updates to the value at the key it monitors
*/
@ExperimentalListener
public fun removeListener(listener: Listener)

/**
* A handle to a listener instance returned by [addListener] so it can be passed to [removeListener].
*/
@ExperimentalListener
interface Listener
}
51 changes: 51 additions & 0 deletions common/src/test/kotlin/com/russhwolf/settings/SettingsTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,55 @@ class SettingsTest {
assertEquals(1, settingsA["a", -1])
assertEquals(1, settingsB["a", -1])
}

@Test
@UseExperimental(ExperimentalListener::class)
fun listener() {
var invocationCount = 0
val callback = { invocationCount += 1 }

// No invocation for call before listener was set
settings["a"] = 2
val listener = settings.addListener("a", callback)
assertEquals(0, invocationCount)

// No invocation on set to existing value
settings["a"] = 2
assertEquals(0, invocationCount)

// New invocation on value change
settings["a"] = 1
assertEquals(1, invocationCount)

// No invocation if value unchanged
settings["a"] = 1
assertEquals(1, invocationCount)

// New invocation on remove
settings -= "a"
assertEquals(2, invocationCount)

// New invocation on re-add with same value
settings["a"] = 1
assertEquals(3, invocationCount)

// No invocation on other key change
settings["b"] = 1
assertEquals(3, invocationCount)

// Second listener at the same key also gets called
var invokationCount2 = 0
val callback2 = { invokationCount2 += 1 }
settings.addListener("a", callback2)
settings["a"] = 3
assertEquals(4, invocationCount)
assertEquals(1, invokationCount2)

// No invocation on listener which is removed
settings.removeListener(listener)
settings["a"] = 2
assertEquals(4, invocationCount)
assertEquals(2, invokationCount2)
}

}
7 changes: 7 additions & 0 deletions ios/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,10 @@ afterEvaluate { project ->
commandLine("xcrun", "simctl", "spawn", "iPhone 8", testExecutable)
}
}

components.main {
extraOpts '-Xuse-experimental=kotlin.Experimental'
}
components.test {
extraOpts '-Xuse-experimental=kotlin.Experimental'
}
58 changes: 57 additions & 1 deletion ios/src/main/kotlin/com/russhwolf/settings/PlatformSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@

package com.russhwolf.settings

import platform.Foundation.NSNotification
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSUserDefaults
import platform.Foundation.NSBundle
import platform.Foundation.NSUserDefaultsDidChangeNotification
import platform.darwin.NSObjectProtocol

/**
* A collection of storage-backed key-value data
Expand Down Expand Up @@ -154,4 +157,57 @@ public actual class PlatformSettings public constructor(private val delegate: NS
*/
public actual override fun getBoolean(key: String, defaultValue: Boolean): Boolean =
if (hasKey(key)) delegate.boolForKey(key) else defaultValue

/**
* Adds a listener which will call the supplied [callback] anytime the value at [key] changes. A [Listener]
* reference is returned which should be passed to [removeListener] when you no longer need it so that the
* associated platform resources can be cleaned up.
*
* No attempt is made in the current implementation to safely handle multithreaded interaction with the listener, so
* it's recommended that interaction with the listener APIs be confined to the main UI thread.
*/
@ExperimentalListener
actual override fun addListener(key: String, callback: () -> Unit): Settings.Listener {
val cache = Listener.Cache(delegate.objectForKey(key))

val block = { _: NSNotification? ->
/*
We'll get called here on any update to the underlying NSUserDefaults delegate. We use a cache to determine
whether the value at this listener's key changed before calling the user-supplied callback.
*/
val prev = cache.value
val current = delegate.objectForKey(key)
if (prev != current) {
callback()
cache.value = current
}
}
val observer = NSNotificationCenter.defaultCenter.addObserverForName(
name = NSUserDefaultsDidChangeNotification,
`object` = delegate,
queue = null,
usingBlock = block
)
return Listener(observer)
}

/**
* Unsubscribes the [listener] from receiving updates to the value at the key it monitors
*/
@ExperimentalListener
actual override fun removeListener(listener: Settings.Listener) {
val platformListener = listener as? Listener
val listenerDelegate = platformListener?.delegate
listenerDelegate?.let(NSNotificationCenter.defaultCenter::removeObserver)
}

/**
* A handle to a listener instance created in [addListener] so it can be passed to [removeListener]
*
* On the iOS platform, this is a wrapper around the object returned by [NSNotificationCenter.addObserverForName]
*/
@ExperimentalListener
actual class Listener internal constructor(internal val delegate: NSObjectProtocol) : Settings.Listener {
internal class Cache(var value: Any?)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ package com.russhwolf.settings.example

import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.CheckBox
import android.widget.EditText
import android.widget.Spinner
import android.widget.TextView
Expand All @@ -34,6 +37,7 @@ class MainActivity : AppCompatActivity() {
private val getButton by lazy { findViewById<Button>(R.id.get_button) }
private val removeButton by lazy { findViewById<Button>(R.id.remove_button) }
private val clearButton by lazy { findViewById<Button>(R.id.clear_button) }
private val loggerCheckBox by lazy { findViewById<CheckBox>(R.id.logger_checkbox) }
private val output by lazy { findViewById<TextView>(R.id.output) }

override fun onCreate(savedInstanceState: Bundle?) {
Expand Down Expand Up @@ -74,5 +78,15 @@ class MainActivity : AppCompatActivity() {
output.text = "Settings cleared!"
}

typesSpinner.onItemSelectedListener = object: AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
loggerCheckBox.isChecked = settingsRepository.mySettings[position].isLoggingEnabled
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}
loggerCheckBox.setOnCheckedChangeListener { _, isChecked ->
val position = typesSpinner.selectedItemPosition
settingsRepository.mySettings[position].isLoggingEnabled = isChecked
}
}
}
7 changes: 7 additions & 0 deletions sample/app-android/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@
android:text="Clear All Values"
/>

<CheckBox
android:id="@+id/logger_checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Enable Logging"
/>

<TextView
android:id="@+id/output"
android:layout_width="wrap_content"
Expand Down
Loading

0 comments on commit b3c6f01

Please sign in to comment.