Elegantly Checking and Managing Feature Flags in Android with Kotlin
Many projects have functionality management via feature flags (feature-toggle). Somewhere they are used for testing, somewhere for A/B tests. Such flags come either from the company’s server or from a third-party server (for example, Firebase Remote Config). MyBook has developed a certain approach to implementation, which we want to share with the world.
Let’s define what we want:
- getting data of different types from the application config;
- the ability to easily and quickly change values when testing assemblies;
- support for different sources;
- default values.
First, we need an interface to access the application config data. We will pass this interface everywhere where the config values are used. If there is no value in the config, we will throw an error.
interface ApplicationConfig {
fun getString(key: String): String
fun getBoolean(key: String): Boolean
fun getLong(key: String): Long
}
class NoValueException(message: String) : Exception(message)
Many are familiar with the FirebaseRemoteConfig class and its methods. We used it everywhere in our application. When we decided to abstract from it, a very similar interface was born.
We will have several sources, so let’s create a composite config that will help combine other configs. The task of this class is to run through all sources from the highest priority to the lowest priority and extract values from them. If the value is found in the higher priority one, then there is no need to go further. Thus, during testing, for example, the debug config can override the Firebase config if you add it to the composite config earlier.
class CompositeApplicationConfig(
private vararg val appConfigs: ApplicationConfig
) : ApplicationConfig {
override fun hasFeature(key: String): Boolean =
getValue(key) { hasFeature(key) }
override fun getString(key: String): String =
getValue(key) { getString(key) }
override fun getBoolean(key: String): Boolean =
getValue(key) { getBoolean(key) }
override fun getLong(key: String): Long =
getValue(key) { getLong(key) }
private inline fun <reified T> getValue(
key: String,
crossinline getTypedValue: ApplicationConfig.() -> T
): T =
appConfigs
.asSequence()
.map {
try {
it.getTypedValue()
} catch (e: NoValueException) {
null
}
}
.filter { it != null }
.firstOrNull() ?: throw NoValueException("There is no value for key [$key]")
}
To test assemblies, we added the ability to load a config from a file on disk.
import java.util.Properties
class DebugApplicationConfig : ApplicationConfig {
private val filePath = "/data/local/tmp/"
private val fileName =
BuildConfig.APPLICATION_ID + ".config"
private val file = File(filePath, fileName)
override fun getString(key: String): String =
properties.getString(key)
override fun getBoolean(key: String): Boolean =
properties.getBoolean(key)
override fun getLong(key: String): Long =
properties.getLong(key)
private fun Properties.getBoolean(key: String): Boolean =
when (val value = getString(key)) {
"true", "false" -> value.toBoolean()
else -> throw NoValueException("There is no value for key [$key]")
}
private fun Properties.getLong(key: String): Long =
getString(key).toLong()
private val properties: Properties
get() {
if (!file.exists()) {
throw NoValueException(
"Config file [${file.absolutePath}] not exist."
)
}
return Properties()
.apply {
FileInputStream(file)
.use(this::load)
}
}
}
It is worth noting that when trying to get a value from DebugApplicationConfig, we open the file for reading each time. This makes it possible to update the config on the fly, without restarting the application. Not very optimal, but this code is only for debugging, it will not reach real users.
For FirebaseRemoteConfig, we got this implementation.
class FirebaseApplicationConfig constructor(
private val firebaseRemoteConfig: FirebaseRemoteConfig
) : ApplicationConfig {
override fun getString(key: String): String =
getValue(key).asString()
override fun getBoolean(key: String): Boolean =
getValue(key).asBoolean()
override fun getLong(key: String): Long =
getValue(key).asLong()
private fun getValue(key: String): FirebaseRemoteConfigValue =
firebaseRemoteConfig.getValue(key)
.apply {
if (source == FirebaseRemoteConfig.VALUE_SOURCE_STATIC) {
throw NoValueException("No value for key [$key]")
}
}
}
We do not set default values when initializing FirebaseRemoteConfig, we have a separate source for this. When we get a value by key from FirebaseRemoteConfig, we look at the source field. It allows us to understand where the value came from.
If VALUE_SOURCE_STATIC, then there is no value,
if VALUE_SOURCE_DEFAULT, then this is the default value,
if VALUE_SOURCE_REMOTE,then the value is from the Firebase server.
If we see VALUE_SOURCE_STATIC in the source, this means that the Firebase server does not have this parameter and we must go further along the chain of sources.
The standard methods getString, getBoolean, getLong of FirebaseRemoteConfig return some of their default values in the case when no value is set for them on the Firebase server. Therefore, we get the string, and then convert it to Boolean and Long.
Initialization of the composite config:
val configs = mutableListOf<ApplicationConfig>()
if (BuildConfig.DEBUG) {
configs += DebugApplicationConfig()
}
configs += FirebaseApplicationConfig()
CompositeApplicationConfig(*configs.toTypedArray())
That’s all for the config configuration.
Interactors and invoke operators in Kotlin
A small digression to briefly talk about an interesting feature of the Kotlin language. Like in some other languages, in Kotlin you can override operators. But in addition to standard operations such as addition, multiplication, etc., you can create an invoke operator. If you write a class that has only one public method with an invoke operator, then its instance can be called as a regular function.
Look at the example:
class GetAndroidTeamSize {
operator fun invoke(): Int {
return 6
}
}fun main() {
val getAndroidTeamSize = GetAndroidTeamSize() val androidTeamSize: Int = getAndroidTeamSize() if (androidTeamSize > 10) {
doNothingJustTalkAllDayLong()
} else {
doTasks()
}
}
It looks wild, but by passing an instance of such a class through DI, you can call it as a function in your code. Thus, you can take out pieces of any logic into separate classes. We call such classes interactors. Through the same DI, you can pass the necessary dependencies into them, which the code that wants to use such an interactor no longer needs to worry about.
We got interactors that receive data as input in the form of some abstractions and return some new result. Such classes are very easy to test with unit tests, mocking the input data.
Reading config from interactors
Let’s consider obtaining values from the config using boolean as an example.
class GetApplicationConfigBoolean(
private val applicationConfig: ApplicationConfig
) {
operator fun invoke(
propertyName: String,
defaultValue: Boolean
): Boolean =
try {
applicationConfig.getBoolean(propertyName)
} catch (e: NoValueException) {
defaultValue
}
}
Interactor that checks if a feature flag is enabled:
class IsFeatureEnabled(
private val getApplicationConfigBoolean: GetApplicationConfigBoolean
) {
operator fun invoke(
featureName: String,
defaultValue: Boolean = false
): Boolean =
getApplicationConfigBoolean(featureName, defaultValue)
}
For a product feature, you can use an interactor like this:
class IsNewsPhotoEnabled(
private val isFeatureEnabled: IsFeatureEnabled
) {
operator fun invoke(): Boolean =
isFeatureEnabled("news_photo", false)
}
All the above classes are initialized via the DI framework, and a ready-made instance of IsNewsPhotoEnabled arrives at the final point of use, by calling which we can change the logic of the application. You can show or hide interface blocks, you can change the logic of processing button clicks, etc.
newsPhoto.visibility =
if (isNewsPhotoEnabled()) {
View.VISIBLE
} else {
View.GONE
}
In addition to feature flags, you can get any data from the application config in the same way. For example, you can get the number of trial days of a subscription and conduct AB tests. This is how an interactor that gets the number of trial days of a subscription and conducts AB tests will look like:
class GetTrialDays(
val getApplicationConfigLong: GetApplicationConfigLong
) {
operator fun invoke(): Long =
getApplicationConfigLong("trial_days", 14)
}
There is a more convenient way to manage features — through links, or rather these are not just links, but deep links, to which the application subscribes through rules in the manifest.
Deeplink is a deep link that directs the user directly to the mobile application, bypassing unnecessary barriers. You can read more here.
To use links in the application, we will need to supplement our implementation a little.
UserApplicationConfig will have the highest priority in the composite config. Its values will be stored in SharedPreferences.
class UserApplicationConfig(
private val preferences: SharedPreferences
) : ApplicationConfig { override fun getString(key: String): String =
preferences
.ifExist(key)
.getString(key, null)!! override fun getBoolean(key: String): Boolean =
preferences
.ifExist(key)
.getBoolean(key, false) override fun getLong(key: String): Long =
preferences
.ifExist(key)
.getLong(key, -1) fun setBooleanValue(key: String, value: Boolean) =
preferences.edit()
.putBoolean(key, value)
.apply() fun setLongValue(key: String, value: Long) =
preferences.edit()
.putLong(key, value)
.apply() fun setStringValue(key: String, value: String) =
preferences.edit()
.putString(key, value)
.apply() fun removeByKey(key: String) =
preferences.edit()
.remove(key)
.apply() private fun SharedPreferences.ifExist(key: String): SharedPreferences =
apply {
if (!contains(key)) {
throw NoValueException("Key [$key] not exist in preferences")
}
}}
We will also need a special activity to intercept and process such intents.
Manifest code fragment:
<activity
android:name=".ConfigActivity"
android:label="@string/mybook"> <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <data
android:host="mybook.ru"
android:pathPrefix="/app/android/config"
android:scheme="https" /> </intent-filter> </activity>
Let’s provide the code for such an activity in one big chunk. In the onCreate method, we check just in case that the action corresponds to the required one, then we get the link from data, parse it into Uri, get individual pairs with the given parameters and save each of the parameters in the storage. If there is a key for the parameter, but the value was not passed (another parameter immediately follows or the link ends), then this means that the parameter must be deleted. In this way, you can create links that clear the storage and less priority sources, such as Firebase, begin to operate.
Processing type-a is needed in order to know what type of value has arrived and which storage method to call to save this value.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIntent(intent)
} private fun handleIntent(intent: Intent) {
checkAction(intent)
val uri = getData(intent)
handleUriParameters(uri.queryParameters)
finish()
} private fun checkAction(intent: Intent) {
val action = intent.action
if (action != REQUIRED_ACTION) {
Timber.e("Intent action is [$action]")
val message =
"This activity must be executed with action [$REQUIRED_ACTION] but was [$action]"
throw IllegalArgumentException(message)
}
Timber.d("Intent action is [$action]")
} private fun getData(intent: Intent): Uri {
val data = intent.data
?: throw IllegalArgumentException("No data passed to activity intent")
Timber.d("Intent data is [$data]")
return data
} private fun handleUriParameters(parameters: Map<String, String?>) =
parameters.forEach { (parameterName, queryParameterValue) ->
handleParameter(parameterName, queryParameterValue)
} private fun handleParameter(name: String, rawValue: String?) { if (rawValue.isNullOrBlank()) {
userApplicationConfig.removeByKey(name)
return
} val type = rawValue.substringBefore('_')
val stringValue = rawValue.removePrefix("$type_") userApplicationConfig.apply {
when (type) {
"string" -> setStringValue(name, stringValue)
"boolean" -> setBooleanValue(name, stringValue.toBoolean())
"long" -> setLongValue(name, stringValue.toLong())
else -> throw IllegalArgumentException("Unsupported value type [$type]")
}
} } private val Uri.queryParameters: Map<String, String?>
get() = queryParameterNames.associateWith { getQueryParameter(it) } companion object {
const val REQUIRED_ACTION = "android.intent.action.VIEW"
}
Example of a link to activate our feature flag:
https://mybook.ru/app/android/config?news_photo=boolean_true
To open such a link, you can send it to a colleague or yourself in a chat, you can create an HTML page and open it on the device in the browser, you can even print it on paper in the form of QR codes and stick it on your monitor for quick access! When debugging, we send such links via ADB from the terminal:
adb shell am start -W -a android.intent.action.VIEW -d https://mybook.ru/app/android/config?news_photo=boolean_true
Results
We got a convenient tool for accessing any config parameters in the code, changing these parameters in different ways. Interactors are very light and small, they are easy to test, they are easy to mock while testing the rest of the application code. This is not the final version of the implementation, we have plans to improve this approach. If you have any questions, ask them in the comments, we will try to answer. Thank you for your attention.