diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 8e16cb48..f343dbc3 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { api(androidx.room.runtime) api(rmr.flipper) api(rmr.itemsadapter.viewbinding) + api(libs.konfeature) api(stack.accompanist.themeadapter.core) api(stack.accompanist.themeadapter.material) api(stack.kotlinx.coroutines.android) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..0b520a20 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,5 @@ +[versions] +konfeature = "0.1.0" + +[libraries] +konfeature = { module = "com.redmadrobot.konfeature:konfeature", version.ref = "konfeature" } diff --git a/no-op/build.gradle.kts b/no-op/build.gradle.kts index 1759265a..fc42d5f1 100644 --- a/no-op/build.gradle.kts +++ b/no-op/build.gradle.kts @@ -43,5 +43,6 @@ dependencies { implementation(stack.okhttp) implementation(androidx.appcompat) implementation(rmr.flipper) + implementation(libs.konfeature) implementation(stack.kotlinx.coroutines.android) } diff --git a/no-op/src/main/kotlin/com/redmadrobot/debug/noop/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt b/no-op/src/main/kotlin/com/redmadrobot/debug/noop/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt new file mode 100644 index 00000000..d2ce5bc4 --- /dev/null +++ b/no-op/src/main/kotlin/com/redmadrobot/debug/noop/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt @@ -0,0 +1,13 @@ +package com.redmadrobot.debug.plugin.konfeature + +import android.content.Context +import com.redmadrobot.konfeature.source.FeatureValueSource +import com.redmadrobot.konfeature.source.Interceptor + + +public class KonfeatureDebugPanelInterceptor(context: Context) : Interceptor { + + override val name: String = "NoopDebugPanelInterceptor" + + override fun intercept(valueSource: FeatureValueSource, key: String, value: Any): Any? = null +} diff --git a/no-op/src/main/kotlin/com/redmadrobot/debug/noop/plugin/konfeature/KonfeaturePlugin.kt b/no-op/src/main/kotlin/com/redmadrobot/debug/noop/plugin/konfeature/KonfeaturePlugin.kt new file mode 100644 index 00000000..f21cc0ee --- /dev/null +++ b/no-op/src/main/kotlin/com/redmadrobot/debug/noop/plugin/konfeature/KonfeaturePlugin.kt @@ -0,0 +1,8 @@ +package com.redmadrobot.debug.plugin.konfeature + +import com.redmadrobot.konfeature.Konfeature + +public class KonfeaturePlugin( + private val debugPanelInterceptor: KonfeatureDebugPanelInterceptor, + private val konfeature: Konfeature, +) diff --git a/plugins/konfeature/.gitignore b/plugins/konfeature/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/plugins/konfeature/.gitignore @@ -0,0 +1 @@ +/build diff --git a/plugins/konfeature/build.gradle.kts b/plugins/konfeature/build.gradle.kts new file mode 100644 index 00000000..df4f0796 --- /dev/null +++ b/plugins/konfeature/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + id(Plugins.Android.libraryPlagin) + kotlin(Plugins.Kotlin.androidPlugin) + kotlin(Plugins.Kotlin.kapt) + id("convention-publish") +} + +description = "Plugin for konfeature library integration" + +android { + compileSdk = Project.COMPILE_SDK + lint.targetSdk = Project.TARGET_SDK + + defaultConfig { + minSdk = Project.MIN_SDK + + consumerProguardFile("consumer-rules.pro") + } + + buildTypes { + getByName(Project.BuildTypes.release) { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile(Project.Proguard.androidOptimizedRules), + Project.Proguard.projectRules + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + freeCompilerArgs += "-Xexplicit-api=strict" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = androidx.versions.compose.compiler.get() + } + namespace = "com.redmadrobot.debug.plugin.konfeature" +} + +dependencies { + implementation(project(":core")) + implementation(project(":common")) + implementation(androidx.lifecycle.runtime) +} diff --git a/plugins/konfeature/consumer-rules.pro b/plugins/konfeature/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/plugins/konfeature/library.properties b/plugins/konfeature/library.properties new file mode 100644 index 00000000..35d2c9f3 --- /dev/null +++ b/plugins/konfeature/library.properties @@ -0,0 +1,3 @@ +lib_name = plugin-konfeature +lib_vcs=https://github.com/RedMadRobot/debug-panel-android.git +lib_issue_tracker=https://github.com/RedMadRobot/debug-panel-android/issues diff --git a/plugins/konfeature/proguard-rules.pro b/plugins/konfeature/proguard-rules.pro new file mode 100644 index 00000000..5b0d9eb8 --- /dev/null +++ b/plugins/konfeature/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts.kts.kts.kts.kts.kts.kts.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt new file mode 100644 index 00000000..bf8707c8 --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt @@ -0,0 +1,115 @@ +package com.redmadrobot.debug.plugin.konfeature + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import com.redmadrobot.debug.plugin.konfeature.util.JsonConverter +import com.redmadrobot.konfeature.source.FeatureValueSource +import com.redmadrobot.konfeature.source.Interceptor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.json.JSONObject +import timber.log.Timber + +public class KonfeatureDebugPanelInterceptor(context: Context) : Interceptor { + + private val preferences by lazy { + context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE) + } + + private val _valuesFlow = MutableStateFlow(emptyMap()) + + internal val valuesFlow = _valuesFlow.asStateFlow() + + private val mutex = Mutex() + + override val name: String = "DebugPanelInterceptor" + + init { + CoroutineScope(Dispatchers.IO).launch { + _valuesFlow.value = mutex.withLock { fetchValues(preferences) } + } + } + + override fun intercept(valueSource: FeatureValueSource, key: String, value: Any): Any? { + return _valuesFlow.value[key] + ?.let { convertTypeIfNeeded(it, value) } + ?.takeIf { it != value } + } + + /* + * map debugValue from Int to Long, + * from Float to Double, + * from Long to Double if value is Double + */ + private fun convertTypeIfNeeded(debugValue: Any, value: Any): Any { + var result = when { + debugValue is Int -> debugValue.toLong() + debugValue is Float -> debugValue.toDouble() + else -> debugValue + } + if (result is Long && value is Double) { + result = result.toDouble() + } + return result + } + + internal suspend fun setValue(key: String, value: Any) { + _valuesFlow.update { it + (key to value) } + updateValues(_valuesFlow.value) + } + + internal suspend fun resetValue(key: String) { + _valuesFlow.update { it - key } + updateValues(_valuesFlow.value) + } + + internal suspend fun resetAllValues() { + _valuesFlow.value = emptyMap() + updateValues(_valuesFlow.value) + } + + private suspend fun updateValues(map: Map) { + coroutineScope { + launch(Dispatchers.IO) { + mutex.withLock { updateValues(preferences, map) } + } + } + } + + private fun fetchValues(preferences: SharedPreferences): Map { + return try { + val jsonValues = preferences.getString(KEY, EMPTY_MAP) ?: EMPTY_MAP + JsonConverter.toMap(JSONObject(jsonValues)) + } catch (error: Exception) { + Timber.tag(TAG).e(error, "cant fetch debug values") + preferences.edit(commit = true) { remove(KEY) } + emptyMap() + } + } + + private fun updateValues(preferences: SharedPreferences, map: Map) { + try { + val jsonValues = JSONObject(map).toString() + preferences.edit(commit = true) { + putString(KEY, jsonValues) + } + } catch (error: Exception) { + Timber.tag(TAG).e(error, "cant update debug values") + } + } + + private companion object { + private const val EMPTY_MAP = "{}" + private const val FILE_NAME = "debug_panel_interceptor_values" + private const val KEY = "values" + private const val TAG = "DebugPanelInterceptor" + } +} diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePlugin.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePlugin.kt new file mode 100644 index 00000000..d2f0de0b --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePlugin.kt @@ -0,0 +1,29 @@ +package com.redmadrobot.debug.plugin.konfeature + +import androidx.compose.runtime.Composable +import com.redmadrobot.debug.core.internal.CommonContainer +import com.redmadrobot.debug.core.internal.PluginDependencyContainer +import com.redmadrobot.debug.core.plugin.Plugin +import com.redmadrobot.debug.plugin.konfeaure.ui.KonfeatureScreen +import com.redmadrobot.konfeature.Konfeature + +public class KonfeaturePlugin( + private val debugPanelInterceptor: KonfeatureDebugPanelInterceptor, + private val konfeature: Konfeature, +) : Plugin() { + + private companion object { + private const val NAME = "KONFEATURE" + } + + override fun getName(): String = NAME + + override fun getPluginContainer(commonContainer: CommonContainer): PluginDependencyContainer { + return KonfeaturePluginContainer(konfeature, debugPanelInterceptor) + } + + @Composable + override fun content() { + KonfeatureScreen() + } +} diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePluginContainer.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePluginContainer.kt new file mode 100644 index 00000000..2d195378 --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePluginContainer.kt @@ -0,0 +1,15 @@ +package com.redmadrobot.debug.plugin.konfeature + +import com.redmadrobot.debug.core.internal.PluginDependencyContainer +import com.redmadrobot.debug.plugin.konfeature.ui.KonfeatureViewModel +import com.redmadrobot.konfeature.Konfeature + +internal class KonfeaturePluginContainer( + private val konfeature: Konfeature, + private val debugPanelInterceptor: KonfeatureDebugPanelInterceptor, +) : PluginDependencyContainer { + + fun createKonfeatureViewModel(): KonfeatureViewModel { + return KonfeatureViewModel(konfeature, debugPanelInterceptor) + } +} diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/EditConfigValueDialog.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/EditConfigValueDialog.kt new file mode 100644 index 00000000..23f7cd3b --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/EditConfigValueDialog.kt @@ -0,0 +1,189 @@ +package com.redmadrobot.debug.plugin.konfeature.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.Checkbox +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.redmadrobot.debug.plugin.konfeature.R +import com.redmadrobot.debug.plugin.konfeature.ui.data.EditDialogState +import com.redmadrobot.debug.core.R as CoreR + +@Composable +internal fun EditConfigValueDialog( + state: EditDialogState, + onValueChange: (key: String, value: Any) -> Unit, + onValueReset: (key: String) -> Unit, + onDismissRequest: () -> Unit, +) { + val initialValue = state.value + var value by remember { mutableStateOf(state.value) } + var isInputEmpty by remember { mutableStateOf(false) } + val saveEnabled by remember { + derivedStateOf { !isInputEmpty && initialValue != value } + } + + AlertDialog( + backgroundColor = colorResource(id = CoreR.color.super_light_gray), + title = { + Text(text = stringResource(id = R.string.konfeature_plugin_edit_dialog_title, state.key)) + }, + text = { + when (initialValue) { + is Boolean -> BooleanEditInput(initialValue, onValueChange = { value = it }) + is Long -> LongEditInput( + initialValue, + onValueChange = { value = it }, + onEmptyInput = { isInputEmpty = it } + ) + + is Double -> DoubleEditInput( + initialValue, + onValueChange = { value = it }, + onEmptyImput = { isInputEmpty = it } + ) + + is String -> StringEditInput(initialValue, onValueChange = { value = it }) + } + }, + onDismissRequest = onDismissRequest, + buttons = { + Row( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + ) { + Button(onClick = onDismissRequest) { + Text(text = stringResource(id = R.string.konfeature_plugin_close)) + } + Spacer(modifier = Modifier.weight(1f)) + Button( + enabled = saveEnabled, + onClick = { + onValueChange.invoke(state.key, value) + onDismissRequest.invoke() + } + ) { + Text(text = stringResource(id = R.string.konfeature_plugin_save)) + } + if (state.isDebugSource) { + Button( + modifier = Modifier.padding(start = 8.dp), + onClick = { + onValueReset.invoke(state.key) + onDismissRequest.invoke() + } + ) { + Text(text = stringResource(id = R.string.konfeature_plugin_reset)) + } + } + } + } + ) +} + +@Composable +private fun BooleanEditInput( + value: Boolean, + onValueChange: (Any) -> Unit +) { + var checked by remember { mutableStateOf(value) } + Row( + modifier = Modifier.fillMaxWidth() + ) { + Text( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + text = stringResource(id = R.string.konfeature_plugin_edit_dialog_hint_boolean) + ) + Checkbox( + checked = checked, + onCheckedChange = { newChecked -> + checked = newChecked + onValueChange.invoke(newChecked) + } + ) + } +} + +@Composable +private fun LongEditInput( + value: Long, + onValueChange: (Any) -> Unit, + onEmptyInput: (Boolean) -> Unit, +) { + var text by remember { mutableStateOf(value.toString(10)) } + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + label = { Text(text = stringResource(id = R.string.konfeature_plugin_edit_dialog_hint_long)) }, + value = text, + onValueChange = { newText -> + val newValue = newText.toLongOrNull() + if (newValue != null || newText.isEmpty()) { + text = newText + newValue?.let(onValueChange) + onEmptyInput.invoke(newText.isEmpty()) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) +} + +@Composable +private fun DoubleEditInput( + value: Double, + onValueChange: (Any) -> Unit, + onEmptyImput: (Boolean) -> Unit, +) { + var text by remember { mutableStateOf(value.toBigDecimal().toPlainString()) } + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + label = { Text(text = stringResource(id = R.string.konfeature_plugin_edit_dialog_hint_double)) }, + value = text, + onValueChange = { newText -> + val newValue = newText.toDoubleOrNull() + if (newValue != null || newText.isEmpty()) { + text = newText + newValue?.let(onValueChange) + onEmptyImput.invoke(newText.isEmpty()) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal) + ) +} + +@Composable +private fun StringEditInput( + value: String, + onValueChange: (Any) -> Unit, +) { + var text by remember { mutableStateOf(value) } + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + label = { Text(text = stringResource(id = R.string.konfeature_plugin_edit_dialog_hint_string)) }, + value = text, + onValueChange = { newText -> + text = newText + onValueChange.invoke(newText) + }, + ) +} diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt new file mode 100644 index 00000000..e7fbeca2 --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt @@ -0,0 +1,174 @@ +package com.redmadrobot.debug.plugin.konfeaure.ui + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.Button +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.KeyboardArrowUp +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.redmadrobot.debug.core.extension.getPlugin +import com.redmadrobot.debug.core.extension.provideViewModel +import com.redmadrobot.debug.plugin.konfeature.KonfeaturePlugin +import com.redmadrobot.debug.plugin.konfeature.KonfeaturePluginContainer +import com.redmadrobot.debug.plugin.konfeature.R +import com.redmadrobot.debug.plugin.konfeature.ui.EditConfigValueDialog +import com.redmadrobot.debug.plugin.konfeature.ui.KonfeatureViewModel +import com.redmadrobot.debug.plugin.konfeature.ui.data.KonfeatureItem +import com.redmadrobot.debug.plugin.konfeature.ui.data.KonfeatureViewState +import com.redmadrobot.debug.core.R as CoreR + +@OptIn(ExperimentalMaterialApi::class) +@Composable +internal fun KonfeatureScreen( + viewModel: KonfeatureViewModel = provideViewModel { + getPlugin() + .getContainer() + .createKonfeatureViewModel() + }, +) { + val state by viewModel.state.collectAsState(KonfeatureViewState()) + + KonfeatureLayout( + state = state, + onRefreshClick = viewModel::onRefreshClick, + onResetAllClick = viewModel::onResetAllClick, + onCollapseAllClick = viewModel::onCollapseAllClick, + onHeaderClick = viewModel::onConfigHeaderClick, + onEditClick = viewModel::onEditClick + ) + + state.editDialogState?.let { dialogState -> + EditConfigValueDialog( + state = dialogState, + onValueChange = viewModel::onValueChanged, + onValueReset = viewModel::onValueReset, + onDismissRequest = viewModel::onEditDialogCloseClik + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun KonfeatureLayout( + state: KonfeatureViewState, + onEditClick: (String, Any, Boolean) -> Unit, + onRefreshClick: () -> Unit, + onCollapseAllClick: () -> Unit, + onResetAllClick: () -> Unit, + onHeaderClick: (String) -> Unit, +) { + LazyColumn { + stickyHeader { + Row( + modifier = Modifier + .background(colorResource(id = CoreR.color.super_light_gray)) + .padding(horizontal = 16.dp, vertical = 4.dp) + ) { + Button(onClick = onRefreshClick) { + Text(text = stringResource(id = R.string.konfeature_plugin_refresh)) + } + Spacer(modifier = Modifier.weight(1f)) + Button(onClick = onCollapseAllClick) { + Text(text = stringResource(id = R.string.konfeature_plugin_collapse_all)) + } + Spacer(modifier = Modifier.weight(1f)) + Button(onClick = onResetAllClick) { + Text(text = stringResource(id = R.string.konfeature_plugin_reset_all)) + } + } + } + + state.items.forEach { item -> + if (item is KonfeatureItem.Config) { + item(item.name) { + ConfigItem( + item = item, + isCollapsed = item.name in state.collapsedConfigs, + onHeaderClick = onHeaderClick + ) + } + } + + if (item is KonfeatureItem.Value && item.configName !in state.collapsedConfigs) { + item(item.key) { ValueItem(item = item, onEditClick) } + item { Divider(modifier = Modifier.fillMaxWidth()) } + } + } + } +} + + +@Composable +private fun ConfigItem( + isCollapsed: Boolean, + item: KonfeatureItem.Config, + onHeaderClick: (String) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onHeaderClick.invoke(item.name) } + .background(colorResource(id = CoreR.color.super_light_gray)) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text( + modifier = Modifier.weight(1f), + text = item.description.takeIf { it.isNotEmpty() } ?: item.name + ) + val icon = if (isCollapsed) Icons.Outlined.KeyboardArrowUp else Icons.Outlined.KeyboardArrowDown + + Icon( + imageVector = icon, + modifier = Modifier.align(Alignment.CenterVertically), + contentDescription = null + ) + } +} + +@Composable +internal fun ValueItem(item: KonfeatureItem.Value, onEditClick: (String, Any, Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + ) { + Column(Modifier.weight(1f)) { + Text(text = item.description) + Text(text = stringResource(id = R.string.konfeature_plugin_item_key, item.key)) + Text(text = stringResource(id = R.string.konfeature_plugin_item_value, item.value.toString())) + Text( + color = item.sourceColor, + text = stringResource(id = R.string.konfeature_plugin_item_source, item.sourceName) + ) + } + + if (item.editAvailable) { + IconButton( + modifier = Modifier.align(alignment = Alignment.CenterVertically), + onClick = { onEditClick.invoke(item.key, item.value, item.isDebugSource) }) { + Icon(Icons.Outlined.Edit, contentDescription = null) + } + } + } +} diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt new file mode 100644 index 00000000..4c5a44eb --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt @@ -0,0 +1,157 @@ +package com.redmadrobot.debug.plugin.konfeature.ui + +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.viewModelScope +import com.redmadrobot.debug.core.internal.PluginViewModel +import com.redmadrobot.debug.plugin.konfeature.KonfeatureDebugPanelInterceptor +import com.redmadrobot.debug.plugin.konfeature.ui.data.EditDialogState +import com.redmadrobot.debug.plugin.konfeature.ui.data.KonfeatureItem +import com.redmadrobot.debug.plugin.konfeature.ui.data.KonfeatureViewState +import com.redmadrobot.konfeature.FeatureConfigSpec +import com.redmadrobot.konfeature.FeatureValueSpec +import com.redmadrobot.konfeature.Konfeature +import com.redmadrobot.konfeature.source.FeatureValueSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +internal class KonfeatureViewModel( + private val konfeature: Konfeature, + private val debugPanelInterceptor: KonfeatureDebugPanelInterceptor, +) : PluginViewModel() { + + private val _state = MutableStateFlow(KonfeatureViewState()) + + val state: Flow = _state.asStateFlow() + + init { + debugPanelInterceptor + .valuesFlow + .onEach { updateItems() } + .launchIn(viewModelScope) + } + + fun onValueChanged(key: String, value: Any) { + viewModelScope.launch { + debugPanelInterceptor.setValue(key, value) + } + } + + fun onValueReset(key: String) { + viewModelScope.launch { + debugPanelInterceptor.resetValue(key) + } + } + + fun onConfigHeaderClick(configName: String) { + _state.update { state -> + val newCollapsedConfigs = if (configName in state.collapsedConfigs) { + state.collapsedConfigs - configName + } else { + state.collapsedConfigs + configName + } + state.copy(collapsedConfigs = newCollapsedConfigs) + } + } + + fun onRefreshClick() { + viewModelScope.launch { updateItems() } + } + + fun onResetAllClick() { + viewModelScope.launch { + debugPanelInterceptor.resetAllValues() + } + } + + fun onCollapseAllClick() { + _state.update { state -> + val collapsedConfigs = state.items + .asSequence() + .filterIsInstance(KonfeatureItem.Config::class.java) + .map { it.name } + .toSet() + state.copy(collapsedConfigs = collapsedConfigs) + } + } + + fun onEditClick(key: String, value: Any, isDebugSource: Boolean) { + _state.update { it.copy(editDialogState = EditDialogState(key, value, isDebugSource)) } + } + + fun onEditDialogCloseClik() { + _state.update { it.copy(editDialogState = null) } + } + + private suspend fun updateItems() { + val items = withContext(Dispatchers.IO) { getItems(konfeature) } + _state.update { it.copy(items = items) } + } + + private fun getItems(konfeature: Konfeature): List { + return konfeature.spec.fold(mutableListOf()) { acc, configSpec -> + acc.apply { + add(createConfigItem(configSpec)) + addAll(configSpec.values.map { valueSpec -> + createConfigValueItem( + configName = configSpec.name, + valueSpec = valueSpec, + konfeature = konfeature + ) + }) + } + } + } + + private fun createConfigItem(config: FeatureConfigSpec): KonfeatureItem.Config { + return KonfeatureItem.Config( + name = config.name, + description = config.description + ) + } + + private fun createConfigValueItem( + configName: String, + valueSpec: FeatureValueSpec, + konfeature: Konfeature, + ): KonfeatureItem.Value { + val configValue = konfeature.getValue(valueSpec) + + val sourceColor = when (configValue.source) { + FeatureValueSource.Default -> Color.Gray + is FeatureValueSource.Interceptor -> Color.Red + is FeatureValueSource.Source -> Color.Green + } + + return KonfeatureItem.Value( + key = valueSpec.key, + value = configValue.value, + configName = configName, + sourceName = getSourceName(configValue.source), + sourceColor = sourceColor, + description = valueSpec.description, + isDebugSource = isDebugSource(configValue.source), + ) + } + + private fun isDebugSource(source: FeatureValueSource): Boolean { + return (source as? FeatureValueSource.Interceptor)?.name == debugPanelInterceptor.name + } + + private fun getSourceName(source: FeatureValueSource): String { + return when (source) { + FeatureValueSource.Default -> "Default" + is FeatureValueSource.Interceptor -> source.name + is FeatureValueSource.Source -> source.name + else -> "Unknown" + } + } +} + + diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/EditDialogState.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/EditDialogState.kt new file mode 100644 index 00000000..0a5fe92c --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/EditDialogState.kt @@ -0,0 +1,7 @@ +package com.redmadrobot.debug.plugin.konfeature.ui.data + +internal class EditDialogState( + val key: String, + val value: Any, + val isDebugSource: Boolean +) diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureItem.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureItem.kt new file mode 100644 index 00000000..1c497277 --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureItem.kt @@ -0,0 +1,32 @@ +package com.redmadrobot.debug.plugin.konfeature.ui.data + +import androidx.compose.ui.graphics.Color + +internal sealed interface KonfeatureItem { + + data class Config( + val name: String, + val description: String, + ) : KonfeatureItem + + data class Value( + val key: String, + val configName: String, + val value: Any, + val description: String, + val sourceName: String, + val sourceColor: Color, + val isDebugSource: Boolean + ) : KonfeatureItem { + + val editAvailable: Boolean + get() = when (value) { + is Boolean, + is String, + is Long, + is Double -> true + else -> false + } + + } +} diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt new file mode 100644 index 00000000..7f978311 --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt @@ -0,0 +1,7 @@ +package com.redmadrobot.debug.plugin.konfeature.ui.data + +internal data class KonfeatureViewState( + val collapsedConfigs: Set = emptySet(), + val items: List = emptyList(), + val editDialogState: EditDialogState? = null +) diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/util/JsonConverter.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/util/JsonConverter.kt new file mode 100644 index 00000000..eb76ffd0 --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/util/JsonConverter.kt @@ -0,0 +1,37 @@ +package com.redmadrobot.debug.plugin.konfeature.util + +import org.json.JSONArray +import org.json.JSONObject + +internal object JsonConverter { + + fun toMap(jsonobj: JSONObject): Map { + val map = mutableMapOf() + val keys = jsonobj.keys() + while (keys.hasNext()) { + val key = keys.next() + var value = jsonobj[key] + if (value is JSONArray) { + value = toList(value) + } else if (value is JSONObject) { + value = toMap(value) + } + map[key] = value + } + return map + } + + fun toList(array: JSONArray): List { + val list = mutableListOf() + for (i in 0 until array.length()) { + var value = array[i] + if (value is JSONArray) { + value = toList(value) + } else if (value is JSONObject) { + value = toMap(value) + } + list.add(value) + } + return list + } +} diff --git a/plugins/konfeature/src/main/res/values/strings.xml b/plugins/konfeature/src/main/res/values/strings.xml new file mode 100644 index 00000000..94797008 --- /dev/null +++ b/plugins/konfeature/src/main/res/values/strings.xml @@ -0,0 +1,21 @@ + + + Edit: %s + + Boolean value: + Long value: + Double value: + String value: + + key: %s + source: %s + value: %s + + Reset + Save + Close + + Refresh + Collapse All + Reset All + diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 593ba518..052d6735 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { implementation(stack.material) implementation(androidx.constraintlayout) implementation(rmr.flipper) + implementation(libs.konfeature) implementation(stack.timber) implementation(stack.kotlinx.coroutines.android) implementation(androidx.lifecycle.runtime) @@ -54,14 +55,8 @@ dependencies { debugImplementation(project(":plugins:app-settings")) debugImplementation(project(":plugins:flipper")) debugImplementation(project(":plugins:variable")) + debugImplementation(project(":plugins:konfeature")) releaseImplementation(project(":no-op")) -// debugImplementation("com.redmadrobot.debug:panel-core:${project.version}") -// debugImplementation("com.redmadrobot.debug:accounts-plugin:${project.version}") -// debugImplementation("com.redmadrobot.debug:servers-plugin:${project.version}") -// debugImplementation("com.redmadrobot.debug:app-settings-plugin:${project.version}") -// debugImplementation("com.redmadrobot.debug:flipper-plugin:${project.version}") -// debugImplementation("com.redmadrobot.debug:variable-plugin:${project.version}") -// releaseImplementation("com.redmadrobot.debug:panel-no-op:${project.version}") implementation(stack.retrofit) } diff --git a/sample/src/main/kotlin/com/redmadrobot/debug_sample/App.kt b/sample/src/main/kotlin/com/redmadrobot/debug_sample/App.kt index e0830914..77a5ef41 100644 --- a/sample/src/main/kotlin/com/redmadrobot/debug_sample/App.kt +++ b/sample/src/main/kotlin/com/redmadrobot/debug_sample/App.kt @@ -7,6 +7,8 @@ import com.redmadrobot.debug.plugin.accounts.AccountsPlugin import com.redmadrobot.debug.plugin.accounts.data.model.DebugAccount import com.redmadrobot.debug.plugin.appsettings.AppSettingsPlugin import com.redmadrobot.debug.plugin.flipper.FlipperPlugin +import com.redmadrobot.debug.plugin.konfeature.KonfeatureDebugPanelInterceptor +import com.redmadrobot.debug.plugin.konfeature.KonfeaturePlugin import com.redmadrobot.debug.plugin.servers.ServersPlugin import com.redmadrobot.debug.plugin.servers.data.model.DebugServer import com.redmadrobot.debug.plugin.variable.VariablePlugin @@ -16,11 +18,14 @@ import com.redmadrobot.debug_sample.debug_data.DebugFlipperFeaturesProvider import com.redmadrobot.debug_sample.debug_data.DebugServersProvider import com.redmadrobot.debug_sample.debug_data.DebugVariableWidgetsProvider import com.redmadrobot.debug_sample.storage.AppTestSettings +import com.redmadrobot.debug_sample.storage.TestKonfeatureProvider class App : Application() { override fun onCreate() { super.onCreate() + val debugPanelInterceptor = KonfeatureDebugPanelInterceptor(this) + DebugPanel.initialize( application = this, config = DebugPanelConfig(shakerMode = false), @@ -45,6 +50,10 @@ class App : Application() { VariablePlugin( customWidgets = DebugVariableWidgetsProvider().provideData() ), + KonfeaturePlugin( + debugPanelInterceptor = debugPanelInterceptor, + konfeature = TestKonfeatureProvider.create(debugPanelInterceptor), + ), ) ) } diff --git a/sample/src/main/kotlin/com/redmadrobot/debug_sample/storage/TestKonfeatureProvider.kt b/sample/src/main/kotlin/com/redmadrobot/debug_sample/storage/TestKonfeatureProvider.kt new file mode 100644 index 00000000..c9a58a5c --- /dev/null +++ b/sample/src/main/kotlin/com/redmadrobot/debug_sample/storage/TestKonfeatureProvider.kt @@ -0,0 +1,138 @@ +package com.redmadrobot.debug_sample.storage + +import com.redmadrobot.debug.plugin.konfeature.KonfeatureDebugPanelInterceptor +import com.redmadrobot.konfeature.FeatureConfig +import com.redmadrobot.konfeature.Konfeature +import com.redmadrobot.konfeature.Logger +import com.redmadrobot.konfeature.builder.konfeature +import com.redmadrobot.konfeature.source.FeatureSource +import com.redmadrobot.konfeature.source.SourceSelectionStrategy +import timber.log.Timber + +internal object TestKonfeatureProvider { + + fun create(debugPanelInterceptor: KonfeatureDebugPanelInterceptor): Konfeature { + return konfeature { + register(FeatureConfig1()) + register(FeatureConfig2()) + register(FeatureConfig3()) + addInterceptor(debugPanelInterceptor) + addSource(object : FeatureSource { + override val name: String = "SampleFeatureSource" + + override fun get(key: String): Any? { + return key == "boolean_feature_2" + } + }) + setLogger(object : Logger { + override fun log(severity: Logger.Severity, message: String) { + when (severity) { + Logger.Severity.WARNING -> Timber.tag("Konfeature").w(message) + Logger.Severity.INFO -> Timber.tag("Konfeature").i(message) + } + } + }) + } + } + + class FeatureConfig1 : FeatureConfig( + name = "FeatureConfig1", + description = "feature config number one" + ) { + val booleanFeature1 by toggle( + key = "boolean_feature_1", + description = "boolean feature one", + defaultValue = false, + ) + + val booleanFeature2 by toggle( + key = "boolean_feature_2", + description = "boolean feature two", + defaultValue = false, + sourceSelectionStrategy = SourceSelectionStrategy.Any + ) + + val doubleFeature1: Double by value( + key = "double_feature_1", + description = "double feature one", + defaultValue = 999.99, + ) + + val stringFeature1: String by value( + key = "string_feature_1", + description = "string feature one", + defaultValue = "String feature 1", + ) + } + + class FeatureConfig2 : FeatureConfig( + name = "FeatureConfig2", + description = "feature config number two" + ) { + val booleanFeature3 by toggle( + key = "boolean_feature_3", + description = "boolean feature three", + defaultValue = false, + ) + + val booleanFeature4 by toggle( + key = "boolean_feature_4", + description = "boolean feature foure", + defaultValue = false, + ) + + val longFeature1: Long by value( + key = "long_feature_1", + description = "long feature one", + defaultValue = 100, + ) + } + + class FeatureConfig3 : FeatureConfig( + name = "FeatureConfig3", + description = "feature config number three" + ) { + val booleanFeature5 by toggle( + key = "boolean_feature_5", + description = "boolean feature five", + defaultValue = false, + ) + + val booleanFeature6 by toggle( + key = "boolean_feature_6", + description = "boolean feature six", + defaultValue = false, + ) + + val booleanFeature7 by toggle( + key = "boolean_feature_7", + description = "boolean feature seven", + defaultValue = false, + ) + + val booleanFeature8 by toggle( + key = "boolean_feature_8", + description = "boolean feature eight", + defaultValue = false, + ) + + val booleanFeature9 by toggle( + key = "boolean_feature_9", + description = "boolean feature nine", + defaultValue = false, + ) + + val booleanFeature10 by toggle( + key = "boolean_feature_10", + description = "boolean feature ten", + defaultValue = false, + ) + + val booleanFeature11 by toggle( + key = "boolean_feature_11", + description = "boolean feature eleven", + defaultValue = false, + ) + } + +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 4756dd6e..b4e91f41 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,7 +42,8 @@ include( ":plugins:servers", ":plugins:app-settings", ":plugins:flipper", - ":plugins:variable" + ":plugins:variable", + ":plugins:konfeature", ) include(":sample")