So one of the things we will need to do in most Multiplatform projects is manage configuration. Different platforms will store things differently. So we will have to use the expect/ actual thing again.
Here is my recipe for those that don’t want to read a novel: Kotlin Multiplatform Persistent Configuration
The first thing our config will need to be able to do is contain a depth that is a Double:
class ConfigTest {
@Test
fun `config has last depth double`() {
val config: Config? = TestConfig()
config.lastDepth()
.shouldNotBeNull()
.shouldBeInstanceOf<Double>()
}
}
We create an interface Config.lastDepth()
and a TestConfig
that implements it:
class ConfigTest {
@Test
fun `config has last depth double`() {
val config: Config = TestConfig()
config.lastDepth()
.shouldNotBeNull()
.shouldBeInstanceOf<Double>()
}
}
Then we need to make sure that our configuration stores and loads no matter what platform we are on. We will use a PlatformConfig
that is an expect/ actual
implementation here. Test for storing and loading lastDepth
:
class PlatformConfigStoresAndLoadsTest {
@Test
fun `platform config stores and loads depth`() {
val config: Config = PlatformConfig()
val depth = 123.45
config.setLastDepth(depth)
config.store()
config.load()
config.getLastDepth()
.shouldNotBeNull()
.shouldBeExactly(depth)
}
}
Note I needed a set and a get and it was an interface so I used the java style. Making PlatformConfig
expect:
expect class PlatformConfig(): Config
requires actuals in all the targets:
//PlatformConfig.desktop.kt:
actual class PlatformConfig actual constructor() : Config {
override fun setLastDepth(lastDepth: Double) {
TODO("Not yet implemented")
}
override fun getLastDepth(): Double? {
TODO("Not yet implemented")
}
override fun store() {
TODO("Not yet implemented")
}
override fun load() {
TODO("Not yet implemented")
}
}
//PlatformConfig.ios.kt
actual class PlatformConfig actual constructor() : Config {
override fun setLastDepth(lastDepth: Double) {
TODO("Not yet implemented")
}
override fun getLastDepth(): Double? {
TODO("Not yet implemented")
}
override fun store() {
TODO("Not yet implemented")
}
override fun load() {
TODO("Not yet implemented")
}
}
//PlatformConfig.android.kt
actual class PlatformConfig actual constructor() : Config {
override fun setLastDepth(lastDepth: Double) {
TODO("Not yet implemented")
}
override fun getLastDepth(): Double? {
TODO("Not yet implemented")
}
override fun store() {
TODO("Not yet implemented")
}
override fun load() {
TODO("Not yet implemented")
}
}
So a quick word on where tests go. I could make individual tests for each platform implementation. Or I could make commonTests that use the expect/ actual
and get run in each. If you do that you just have to make sure each target set of tests runs all the commonTests. I prefer that way though, it is less duplication.
So now when I run the tests in desktop, I get a not implemented error
Great! Now we are getting somewhere!
So now we need to implement the config storage on the desktop platform, which we will do with the Preferences
java api.
So implementing a couple things we get to what does store and load do? Turns out at least with the Platform stuff, store and load are automatically taken care of. What do we really want to test with the config? That when we set something, later instantiations (is that a word?) retain the new value. So we rewrite the test with the new understanding:
class PlatformConfigStoresAndLoadsTest {
@Test
fun `platform config stores and loads depth`() {
var config: Config = PlatformConfig()
val depth = 123.45
config.setLastDepth(depth)
config = PlatformConfig()
config.getLastDepth()
.shouldNotBeNull()
.shouldBeExactly(depth)
}
}
and get rid of all the store and load from the interface.
Now implementing the desktop version using the preferences api:
actual class PlatformConfig actual constructor() : Config {
private val preferences = Preferences.userRoot()
.node(this::class.java.name)
override fun setLastDepth(lastDepth: Double) {
preferences.putDouble("LAST_DEPTH", lastDepth)
}
override fun getLastDepth(): Double? {
return preferences.getDouble("LAST_DEPTH", 0.0)
}
}
this passes! commit.
repeating for android, I asked github copilot what the preferred method of storing persistent configuration was and it gave me the following code:
import android.content.Context
import android.preference.PreferenceManager
actual class PlatformConfig actual constructor() : Config {
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
override fun setLastDepth(lastDepth: Double) {
preferences.edit().putFloat("LAST_DEPTH", lastDepth.toFloat()).apply()
}
but PreferenceManager
is listed as deprecated by the IDE so I asked copilot what to use instead and it gave me the newer SharedPreferenceManager
:
import android.content.Context
import android.content.SharedPreferences
actual class PlatformConfig actual constructor() : Config {
private val preferences = context.getSharedPreferences(
"DaMonPreferences",
Context.MODE_PRIVATE)
override fun setLastDepth(lastDepth: Double) {
preferences.edit()
.putFloat("LAST_DEPTH", lastDepth.toFloat())
.apply()
}
but that requires a context
which copilot tells me to pass in the constructor which ruins the expect/ actual so that it is no longer generic.
I am beginning to think instead of Config
being an interface that PlatformConfig
with expect/actual
implements, I want Config
to be a concrete class that takes a PlatformConfig
as an argument. The PlatformConfig
can be mocked or overridden for testing, and it can have a platform specific implementation for each platform that is constructed in the main activity for that platform.
Lets revert back to before the config started and try again:
> git stash
> git revert ...
So the first test is about the same:
class ConfigTest {
@Test
fun `config contains a lastDepth that is a double`() {
val config = Config()
config.lastDepth
.shouldBeTypeOf<Double>()
}
}
and the simplest code to make it pass is:
class Config {
val lastDepth: Double = 0.0
}
Now we bring in the persistence test:
class ConfigStoresAndLoadsTest {
@Test
fun `platform config stores and loads depth`() {
var config: Config = Config()
val depth = 123.45
config.lastDepth = depth
config = Config()
config.lastDepth
.shouldBeExactly(depth)
}
}
which of course fails. Now to actually persist something we are going to need something that is different in each platform. So instead of expect/ actual
which requires the constructors be the same, lets use an interface that is made and passed to the config in the platform specific code. This will require us to make platform specific tests but will give us flexibility in the object creation.
So we will need our Config test to take a TestProvider
:
class ConfigTest {
@Test
fun `config contains a lastDepth that is a double`() {
val depth = 1.3
val config = Config(TestConfigProvider(depth))
config.lastDepth
.shouldBeExactly(depth)
}
}
and the config now takes the config provider:
class Config(val configProvider: ConfigProvider) {
var lastDepth by configProvider::lastDepth
}
and the ConfigProvider
is an interface with a lastDepth
var:
interface ConfigProvider {
var lastDepth: Double
}
which as of now has one implementation which is the TestConfigProvider
:
class TestConfigProvider(var depth: Double) : ConfigProvider {
override var lastDepth: Double
get() = depth
set(value) {
depth = value
}
}
now we need to get some platform specific implementations going so in desktopTest/kotlin/config
we add the test:
class DesktopConfigProviderPersistsTest {
@Test
fun `config contains a lastDepth that is a double`() {
val depth = 1.3
val desktopConfigProvider = DesktopConfigProvider()
Config(desktopConfigProvider).lastDepth = depth
Config(DesktopConfigProvider())
.lastDepth
.shouldBeExactly(depth)
}
}
which requires a DesktopConfigProvider
:
class DesktopConfigProvider : ConfigProvider {
override var lastDepth: Double
get() = TODO("Not yet implemented")
set(value) {}
}
running the desktop tests we get 25 passed and 1 failed (because we didn’t implement anything). So lets implement the get and set:
import java.util.prefs.Preferences
class DesktopConfigProvider : ConfigProvider {
private val preferences = Preferences.userNodeForPackage(this::class.java)
override var lastDepth: Double
get() = preferences.getDouble("lastDepth", 0.0)
set(value) {
preferences.putDouble("lastDepth", value)
}
}
Yay! it passes. commit.
Now for android:
class AndroidConfigProviderPersistsDepthTest {
@Test
fun configProviderPersistsDepthTest() {
val depth = 1.3
val context = InstrumentationRegistry.getInstrumentation()
.targetContext
val androidConfigProvider = AndroidConfigProvider(context)
Config(androidConfigProvider).lastDepth = depth
Config(AndroidConfigProvider(context))
.lastDepth
.shouldBeExactly(depth)
}
}
which requires the AndroidConfigProvider
:
import android.content.Context
class AndroidConfigProvider(context: Context?) : ConfigProvider {
override var lastDepth: Double
get() = TODO("Not yet implemented")
set(value) {}
}
now to make that pass we need to use the context to get the SharedPreferences
to access our data:
import android.content.Context
private const val CONFIG_KEY = "daMonConfig"
private const val LAST_DEPTH_KEY = "lastDepth"
class AndroidConfigProvider(context: Context) : ConfigProvider {
private val sharedPreferences = context.getSharedPreferences(
CONFIG_KEY,
Context.MODE_PRIVATE
)
override var lastDepth: Double
get() = getSharedLastDepth()
set(value) {
setSharedLastDepth(value)
}
private fun setSharedLastDepth(value: Double) {
sharedPreferences?.edit()
?.putString(LAST_DEPTH_KEY, value.toString())
?.apply()
}
private fun getSharedLastDepth(): Double {
return (sharedPreferences?.getString(LAST_DEPTH_KEY, "0.0")
?.toDouble()
?: 0.0)
}
}
this passes so we commit!
now lastly we need to do the ios implementation. This will be a bit of an issue since the only mac I have is my daughter’s old macbook pro at it needs a battery management board. So I may look at a service such as MacInCloud or MacStadium for running xcode or intellij on a mac so that I can develop the ios code. I think I will review these in the next blog post.