Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add feature flag concept and screen #4760

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,28 @@ jobs:
- name: Validate Lint
run: ./gradlew lint

unit_tests:
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a very basic job, that most probably should evolve over time.

runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/[email protected]
with:
distribution: 'temurin'
java-version: '17'

- name: Mock google-services.json
run: |
cp .github/mock-google-services.json app/google-services.json
cp .github/mock-google-services.json wear/google-services.json
cp .github/mock-google-services.json automotive/google-services.json

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Run test
run: ./gradlew test

pr_build:
runs-on: ubuntu-latest
permissions:
Expand Down
10 changes: 6 additions & 4 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,8 @@ android {
unitTests.isReturnDefaultValues = true
}

tasks.withType<Test>().configureEach {
useJUnitPlatform {
includeEngines("spek2")
}
tasks.withType<Test> {
useJUnitPlatform()
}

lint {
Expand Down Expand Up @@ -191,6 +189,10 @@ dependencies {

implementation(libs.car.core)
"fullImplementation"(libs.car.projected)

testImplementation(libs.junit.api)
testImplementation(libs.kotlinx.coroutines.test)
testRuntimeOnly(libs.junit.engine)
}

// Disable to fix memory leak and be compatible with the configuration cache.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.settings.developer.features.FeaturesSettingsFragment
import io.homeassistant.companion.android.settings.developer.location.LocationTrackingFragment
import io.homeassistant.companion.android.settings.log.LogFragment
import io.homeassistant.companion.android.settings.server.ServerChooserFragment
Expand Down Expand Up @@ -75,6 +76,16 @@ class DeveloperSettingsFragment : DeveloperSettingsView, PreferenceFragmentCompa
return@setOnPreferenceClickListener true
}
}

findPreference<Preference>("features")?.let {
it.setOnPreferenceClickListener {
parentFragmentManager.commit {
replace(R.id.content, FeaturesSettingsFragment::class.java, null)
addToBackStack(getString(io.homeassistant.companion.android.common.R.string.feature_flag))
}
return@setOnPreferenceClickListener true
}
}
}

private fun startThreadDebug(serverId: Int) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package io.homeassistant.companion.android.settings.developer.features

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.Divider
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Switch
import androidx.compose.material.SwitchDefaults
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.common.R
import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme

@AndroidEntryPoint
class FeaturesSettingsFragment : Fragment() {

private val viewModel: FeaturesSettingsViewModel by viewModels()

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setContent {
HomeAssistantAppTheme {
ScreenContent()
}
}
}
}

override fun onResume() {
super.onResume()
activity?.title = getString(R.string.feature_flag)
}

@Composable
@Preview
private fun ScreenContent() {
val viewState by viewModel.viewStateFlow.collectAsState()

FeatureList(viewState = viewState, interaction = viewModel)
StringFeatureDialog(viewState = viewState, interaction = viewModel)
}

@Composable
private fun FeatureList(
modifier: Modifier = Modifier,
viewState: FeaturesSettingsViewState,
interaction: FeaturesSettingsInteraction
) {
val lazyListState = rememberLazyListState()

LazyColumn(
modifier = modifier,
state = lazyListState
) {
items(viewState.features, { feature -> feature }) { feature ->
when (feature) {
is Feature.StringFeature -> StringItem(feature = feature, interaction = interaction)
is Feature.BooleanFeature -> BooleanItem(feature = feature, interaction = interaction)
}
Divider(
modifier = Modifier.padding(horizontal = 8.dp)
)
}
}
}

@Composable
private fun BooleanItem(
modifier: Modifier = Modifier,
feature: Feature.BooleanFeature,
interaction: FeaturesSettingsInteraction
) {
Row(
modifier = modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier.weight(1f),
text = feature.name
)
Switch(
checked = feature.value,
onCheckedChange = {
interaction.onBooleanFeatureChanged(feature, it)
},
enabled = feature.isUpdatable,
colors = SwitchDefaults.colors(uncheckedThumbColor = colorResource(R.color.colorSwitchUncheckedThumb))
)
}
}

@Composable
private fun StringItem(
modifier: Modifier = Modifier,
feature: Feature.StringFeature,
interaction: FeaturesSettingsInteraction
) {
Row(
modifier = modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier.weight(1f),
text = feature.name
)
TextButton(onClick = {
interaction.onStringFeatureSelected(feature = feature)
}, enabled = feature.isUpdatable) {
Text(text = feature.value)
}
}
}

@Composable
private fun StringFeatureDialog(viewState: FeaturesSettingsViewState, interaction: FeaturesSettingsViewModel) {
viewState.selectedStringFeatures?.let { feature ->
val text = remember { mutableStateOf(feature.value) }

AlertDialog(
onDismissRequest = {
interaction.onStringFeatureSelected(null)
},
dismissButton = {
Button(onClick = {
interaction.onStringFeatureSelected(null)
}) {
Text(stringResource(R.string.string_feature_flag_cancel))
}
},
confirmButton = {
Button(onClick = {
interaction.onStringFeatureChanged(feature, text.value)
}) {
Text(stringResource(R.string.string_feature_flag_update))
}
},
title = { Text(feature.name) },
text = {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth(),
value = text.value,
onValueChange = { value ->
text.value = value
},
singleLine = true
)
}
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.homeassistant.companion.android.settings.developer.features

internal interface FeaturesSettingsInteraction {
fun onBooleanFeatureChanged(feature: Feature.BooleanFeature, newValue: Boolean)
fun onStringFeatureChanged(feature: Feature.StringFeature, newValue: String)
fun onStringFeatureSelected(feature: Feature.StringFeature?)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package io.homeassistant.companion.android.settings.developer.features

import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import io.homeassistant.companion.android.common.util.feature.FeatureValue
import io.homeassistant.companion.android.common.util.feature.FeatureValuesStore
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.launch

@HiltViewModel
internal class FeaturesSettingsViewModel @Inject constructor(private val featureValuesStore: FeatureValuesStore) : ViewModel(), FeaturesSettingsInteraction {
private val viewStateMutableFlow = MutableStateFlow(FeaturesSettingsViewState.empty())
val viewStateFlow = viewStateMutableFlow.asStateFlow()

init {
viewModelScope.launch {
updateViewState()
}
}

private companion object {
private const val TAG = "FeaturesSettingsVM"
}

override fun onBooleanFeatureChanged(feature: Feature.BooleanFeature, newValue: Boolean) {
onFeatureChanged(feature, newValue)
}

override fun onStringFeatureChanged(feature: Feature.StringFeature, newValue: String) {
onFeatureChanged(feature, newValue)
}

override fun onStringFeatureSelected(feature: Feature.StringFeature?) {
viewStateMutableFlow.getAndUpdate { it.copyWith(feature) }
}

@Suppress("UNCHECKED_CAST")
private fun <T> onFeatureChanged(feature: Feature, newValue: T) {
viewModelScope.launch {
val featureValue = featureValuesStore.featuresValue().find { it.feature.key == feature.key }
if (featureValue != null) {
if (featureValue.isFeatureUpdatable()) {
(featureValue as FeatureValue.UpdatableFeatureValue<T>).updateValue(newValue)
updateViewState()
} else {
Log.w(TAG, "${featureValue.feature} not updatable, ignoring the new value")
}
} else {
Log.w(TAG, "${feature.name} not found in the value store, ignoring the new value")
}
}
}

private suspend fun updateViewState() {
viewStateMutableFlow.value = FeaturesSettingsViewState.fromFeatureValues(featureValuesStore.featuresValue())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package io.homeassistant.companion.android.settings.developer.features

import android.os.Parcelable
import androidx.compose.runtime.Immutable
import io.homeassistant.companion.android.common.util.feature.FeatureDefinition
import io.homeassistant.companion.android.common.util.feature.FeatureValue
import kotlinx.parcelize.Parcelize

internal sealed interface Feature : Parcelable {
val name: String

// Unique identifier of a feature
val key: String
val isUpdatable: Boolean

@Parcelize
data class BooleanFeature(override val name: String, override val key: String, override val isUpdatable: Boolean, val value: Boolean) : Feature

@Parcelize
data class StringFeature(override val name: String, override val key: String, override val isUpdatable: Boolean, val value: String) : Feature
}

@Immutable
@Parcelize
internal data class FeaturesSettingsViewState(val features: List<Feature>, val selectedStringFeatures: Feature.StringFeature? = null) : Parcelable {
fun copyWith(selectedStringFeatures: Feature.StringFeature?): FeaturesSettingsViewState {
return copy(selectedStringFeatures = selectedStringFeatures)
}

companion object {
suspend fun fromFeatureValues(featuresValue: Set<FeatureValue<*>>): FeaturesSettingsViewState {
val features = featuresValue.map { featureValue ->
val definition = featureValue.feature
when (definition) {
is FeatureDefinition.BooleanFeatureDefinition ->
Feature.BooleanFeature(definition.featureName, definition.key, featureValue.isFeatureUpdatable(), featureValue.getValue() as Boolean)
is FeatureDefinition.StringFeatureDefinition ->
Feature.StringFeature(definition.featureName, definition.key, featureValue.isFeatureUpdatable(), featureValue.getValue() as String)
}
}

return FeaturesSettingsViewState(features)
}

fun empty() = FeaturesSettingsViewState(emptyList())
}
}
9 changes: 9 additions & 0 deletions app/src/main/res/drawable/ic_flag.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/colorAccent"
android:pathData="M6,3A1,1 0 0,1 7,4V4.88C8.06,4.44 9.5,4 11,4C14,4 14,6 16,6C19,6 20,4 20,4V12C20,12 19,14 16,14C13,14 13,12 11,12C8,12 7,14 7,14V21H5V4A1,1 0 0,1 6,3Z" />
</vector>
Loading