├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .idea ├── detekt.xml ├── dictionaries │ └── project.xml ├── externalDependencies.xml └── vcs.xml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── RELEASING.md ├── build.gradle.kts ├── buildSrc ├── build.gradle.kts ├── settings.gradle.kts └── src │ └── main │ └── kotlin │ ├── convention.detekt.gradle.kts │ ├── convention.library.android.gradle.kts │ ├── convention.library.kotlin.gradle.kts │ └── convention.publishing.gradle.kts ├── config └── detekt │ └── detekt.yml ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── konfeature ├── build.gradle.kts └── src │ ├── commonMain │ └── kotlin │ │ └── com │ │ └── redmadrobot │ │ └── konfeature │ │ ├── FeatureConfig.kt │ │ ├── FeatureConfigSpec.kt │ │ ├── FeatureValue.kt │ │ ├── FeatureValueSpec.kt │ │ ├── Konfeature.kt │ │ ├── Logger.kt │ │ ├── builder │ │ ├── KonfeatureBuilder.kt │ │ └── KonfeatureImpl.kt │ │ ├── exception │ │ └── KonfeatureException.kt │ │ └── source │ │ ├── FeatureSource.kt │ │ ├── FeatureValueSource.kt │ │ ├── Interceptor.kt │ │ └── SourceSelectionStrategy.kt │ └── commonTest │ └── kotlin │ └── com │ └── redmadrobot │ └── konfeature │ ├── KonfeatureTest.kt │ ├── builder │ └── KonfeatureBuilderTest.kt │ └── helper │ ├── KonfeatureTestHelper.kt │ └── TestFeatureConfig.kt ├── release.sh ├── renovate.json ├── sample ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── com │ └── redmadrobot │ └── konfeature │ └── sample │ ├── App.kt │ ├── FeatureToggleDebugPanelInterceptor.kt │ ├── SampleFeatureConfig.kt │ └── SampleSources.kt └── settings.gradle.kts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | max_line_length = 120 11 | # Uncomment if you want to show non-strict recommended guideline 12 | #ij_visual_guides = 100 13 | 14 | # General 15 | ij_continuation_indent_size = 8 16 | ij_smart_tabs = false 17 | ij_wrap_on_typing = false 18 | ij_any_keep_indents_on_empty_lines = false 19 | 20 | # Formatter 21 | ij_formatter_tags_enabled = true 22 | ij_formatter_on_tag = @formatter:on 23 | ij_formatter_off_tag = @formatter:off 24 | 25 | [{*.kt,*.kts}] 26 | # Tabs and Indents 27 | # continuation_indent_size = 4 to match ktlint settings 28 | ij_kotlin_continuation_indent_size = 4 29 | ij_kotlin_keep_indents_on_empty_lines = unset 30 | 31 | # Spaces 32 | ## Before parentheses 33 | ij_kotlin_space_before_if_parentheses = true 34 | ij_kotlin_space_before_for_parentheses = true 35 | ij_kotlin_space_before_while_parentheses = true 36 | ij_kotlin_space_before_catch_parentheses = true 37 | ij_kotlin_space_before_when_parentheses = true 38 | ## Around operators 39 | ij_kotlin_spaces_around_assignment_operators = true 40 | ij_kotlin_spaces_around_logical_operators = true 41 | ij_kotlin_spaces_around_equality_operators = true 42 | ij_kotlin_spaces_around_relational_operators = true 43 | ij_kotlin_spaces_around_additive_operators = true 44 | ij_kotlin_spaces_around_multiplicative_operators = true 45 | ij_kotlin_spaces_around_unary_operator = false 46 | ij_kotlin_spaces_around_range = false 47 | ## Other 48 | ij_kotlin_space_before_comma = false 49 | ij_kotlin_space_after_comma = true 50 | ij_kotlin_space_before_type_colon = false 51 | ij_kotlin_space_after_type_colon = true 52 | ij_kotlin_space_before_extend_colon = true 53 | ij_kotlin_space_after_extend_colon = true 54 | ij_kotlin_insert_whitespaces_in_simple_one_line_method = true 55 | ij_kotlin_spaces_around_function_type_arrow = true 56 | ij_kotlin_spaces_around_when_arrow = true 57 | ij_kotlin_space_before_lambda_arrow = true 58 | 59 | # Wrapping and Braces 60 | ## Keep when reformatting 61 | ij_kotlin_keep_line_breaks = true 62 | ij_kotlin_keep_first_column_comment = true 63 | ## Extends/implements list 64 | ij_kotlin_extends_list_wrap = normal 65 | ij_kotlin_align_multiline_extends_list = false 66 | ij_kotlin_continuation_indent_in_supertype_lists = false 67 | ## Function declaration parameters 68 | ij_kotlin_method_parameters_wrap = on_every_item 69 | ij_kotlin_align_multiline_parameters = true 70 | ij_kotlin_method_parameters_new_line_after_left_paren = true 71 | ij_kotlin_method_parameters_right_paren_on_new_line = true 72 | ij_kotlin_continuation_indent_in_parameter_lists = false 73 | ## Function call arguments 74 | ij_kotlin_call_parameters_wrap = on_every_item 75 | ij_kotlin_align_multiline_parameters_in_calls = false 76 | ij_kotlin_call_parameters_new_line_after_left_paren = true 77 | ij_kotlin_call_parameters_right_paren_on_new_line = true 78 | ij_kotlin_continuation_indent_in_argument_lists = false 79 | ## Function parentheses 80 | ij_kotlin_align_multiline_method_parentheses = false 81 | ## Chained function calls 82 | ij_kotlin_method_call_chain_wrap = normal 83 | ij_kotlin_wrap_first_method_in_call_chain = false 84 | ij_kotlin_continuation_indent_for_chained_calls = false 85 | ## 'if()' statement 86 | ij_kotlin_else_on_new_line = false 87 | ij_kotlin_if_rparen_on_new_line = true 88 | ij_kotlin_continuation_indent_in_if_conditions = false 89 | ## 'do ... while()' statement 90 | ij_kotlin_while_on_new_line = false 91 | ## 'try' statement 92 | ij_kotlin_catch_on_new_line = false 93 | ij_kotlin_finally_on_new_line = false 94 | ## Binary expressions 95 | ij_kotlin_align_multiline_binary_operation = false 96 | ## Wraps 97 | ij_kotlin_assignment_wrap = normal 98 | ij_kotlin_enum_constants_wrap = off 99 | ij_kotlin_class_annotation_wrap = split_into_lines 100 | ij_kotlin_method_annotation_wrap = split_into_lines 101 | ij_kotlin_field_annotation_wrap = split_into_lines 102 | ij_kotlin_parameter_annotation_wrap = off 103 | ij_kotlin_variable_annotation_wrap = off 104 | ## 'when' statements 105 | ij_kotlin_align_in_columns_case_branch = false 106 | ij_kotlin_line_break_after_multiline_when_entry = true 107 | ## Braces placement 108 | ij_kotlin_lbrace_on_next_line = false 109 | ## Expression body functions 110 | ij_kotlin_wrap_expression_body_functions = 1 111 | ij_kotlin_continuation_indent_for_expression_bodies = false 112 | ## Elvis expressions 113 | ij_kotlin_wrap_elvis_expressions = 1 114 | ij_kotlin_continuation_indent_in_elvis = false 115 | 116 | # Blank Lines 117 | ## Keep maximum blank lines 118 | ij_kotlin_keep_blank_lines_in_declarations = 1 119 | ij_kotlin_keep_blank_lines_in_code = 1 120 | ij_kotlin_keep_blank_lines_before_right_brace = 0 121 | ## Minimum blank lines 122 | ij_kotlin_blank_lines_after_class_header = 0 123 | ij_kotlin_blank_lines_around_block_when_branches = 1 124 | ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 125 | 126 | # Imports 127 | ij_kotlin_name_count_to_use_star_import = 5 128 | ij_kotlin_name_count_to_use_star_import_for_members = 3 129 | ij_kotlin_import_nested_classes = false 130 | ij_kotlin_packages_to_use_import_on_demand = java.util.*,kotlinx.android.synthetic.**,io.ktor.** 131 | ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^ 132 | 133 | # Other 134 | ## Trailing comma 135 | ij_kotlin_allow_trailing_comma = true 136 | ij_kotlin_allow_trailing_comma_on_call_site = false 137 | 138 | # Code generation 139 | ## Comment code 140 | ij_kotlin_line_comment_at_first_column = true 141 | ij_kotlin_line_comment_add_space = false 142 | ij_kotlin_line_comment_add_space_on_reformat = false 143 | ij_kotlin_block_comment_at_first_column = true 144 | ij_kotlin_block_comment_add_space = false 145 | 146 | # Compose 147 | ij_kotlin_use_custom_formatting_for_modifiers = true 148 | 149 | # Load/Save 150 | ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL 151 | 152 | [*.kts] 153 | # Always use wildcard imports in scripts 154 | ij_kotlin_name_count_to_use_star_import = 2 155 | 156 | # EditorConfig can not set some of XML code style options. 157 | # Remember to set default Android XML code style: Editor > Code Style > XML > Set from... -> Android 158 | [{**/res/**.xml,**/AndroidManifest.xml}] 159 | # Tabs and Indents 160 | ij_xml_continuation_indent_size = 4 161 | ij_xml_keep_indents_on_empty_lines = unset 162 | 163 | # Other 164 | ij_xml_keep_line_breaks = false 165 | ij_xml_keep_line_breaks_in_text = true 166 | ij_xml_keep_blank_lines = 2 167 | ij_xml_attribute_wrap = normal 168 | ij_xml_text_wrap = normal 169 | ij_xml_align_text = false 170 | ij_xml_align_attributes = false 171 | ij_xml_keep_whitespaces = false 172 | ## Spaces 173 | ij_xml_space_around_equals_in_attribute = false 174 | ij_xml_space_after_tag_name = false 175 | ij_xml_space_inside_empty_tag = true 176 | ## CDATA 177 | ij_xml_keep_whitespaces_around_cdata = preserve 178 | ij_xml_keep_whitespaces_inside_cdata = false 179 | 180 | # Code Generation 181 | ij_xml_line_comment_at_first_column = true 182 | ij_xml_block_comment_at_first_column = true 183 | ij_xml_block_comment_add_space = false 184 | 185 | # Android 186 | ij_xml_use_custom_settings = true 187 | 188 | [*.md] 189 | trim_trailing_whitespace = false 190 | 191 | # Wrapping and Braces 192 | ij_markdown_wrap_text_if_long = false 193 | ij_markdown_wrap_text_inside_blockquotes = false 194 | ## When reformatting 195 | ij_markdown_keep_line_breaks_inside_text_blocks = true 196 | ij_markdown_insert_quote_arrows_on_wrap = true 197 | ij_markdown_format_tables = true 198 | 199 | # Tabs and Indents 200 | ij_markdown_keep_indents_on_empty_lines = unset 201 | 202 | # Blank Lines 203 | ## Keep maximum blank lines 204 | ij_markdown_max_lines_around_header = 1 205 | ij_markdown_max_lines_around_block_elements = 1 206 | ij_markdown_max_lines_between_paragraphs = 1 207 | ## Minimum blank lines 208 | ij_markdown_min_lines_around_header = 1 209 | ij_markdown_min_lines_around_block_elements = 1 210 | ij_markdown_min_lines_between_paragraphs = 1 211 | 212 | # Spaces 213 | ## Force one space 214 | ij_markdown_force_one_space_between_words = true 215 | ij_markdown_force_one_space_after_header_symbol = true 216 | ij_markdown_force_one_space_after_list_bullet = true 217 | ij_markdown_force_one_space_after_blockquote_symbol = true 218 | 219 | [{*.yaml,*.yml}] 220 | indent_size = 2 221 | ij_yaml_keep_indents_on_empty_lines = unset 222 | ij_yaml_keep_line_breaks = true 223 | ij_yaml_spaces_within_brackets = false 224 | 225 | [{*.bash,*.sh,*.zsh}] 226 | indent_size = 2 227 | tab_width = 2 228 | 229 | [*.bat] 230 | end_of_line = crlf 231 | 232 | [*.properties] 233 | ij_properties_keep_blank_lines = true 234 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # 2 | # https://help.github.com/articles/dealing-with-line-endings/ 3 | # 4 | # These are explicitly windows files and should use crlf 5 | *.bat text eol=crlf 6 | 7 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | # Release tag format is v[version] 7 | # For example: v1.3.5 8 | tags: ["v*"] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | check: 14 | name: Check 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout sources 18 | uses: actions/checkout@v4 19 | - name: Setup Java 20 | uses: actions/setup-java@v4 21 | with: 22 | distribution: "temurin" 23 | java-version: 17 24 | - name: Setup Gradle 25 | uses: gradle/actions/setup-gradle@v3 26 | - name: Run Check 27 | run: ./gradlew check detektAll 28 | 29 | publish: 30 | name: Publish 31 | needs: check 32 | runs-on: ubuntu-latest 33 | if: ${{ startsWith(github.ref, 'refs/tags/') }} 34 | 35 | steps: 36 | - name: Checkout sources 37 | uses: actions/checkout@v4 38 | - name: Setup Java 39 | uses: actions/setup-java@v4 40 | with: 41 | distribution: "temurin" 42 | java-version: 17 43 | - name: Setup Gradle 44 | uses: gradle/actions/setup-gradle@v3 45 | - name: Run Publish 46 | run: ./gradlew publish 47 | env: 48 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_USERNAME }} 49 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }} 50 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }} 51 | ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }} 52 | ORG_GRADLE_PROJECT_githubPackagesUsername: ${{ github.actor }} 53 | ORG_GRADLE_PROJECT_githubPackagesPassword: ${{ secrets.GITHUB_TOKEN }} 54 | - name: Extract release notes 55 | uses: ffurrer2/extract-release-notes@v2 56 | with: 57 | release_notes_file: RELEASE_NOTES.md 58 | - name: Create GitHub Release 59 | uses: softprops/action-gh-release@v2 60 | with: 61 | body_path: RELEASE_NOTES.md 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | ## JetBrains IDEs 4 | /.idea/** 5 | *.iml 6 | 7 | # Keep required plugins 8 | !.idea/externalDependencies.xml 9 | 10 | # Keep detekt plugin config 11 | !.idea/detekt.xml 12 | 13 | # Keep VCS config 14 | !.idea/vcs.xml 15 | 16 | # Keep project dictionary 17 | !.idea/dictionaries 18 | !.idea/dictionaries/project.xml 19 | 20 | ## Gradle 21 | # Ignore Gradle project-specific cache directory 22 | .gradle 23 | 24 | # Ignore Gradle build output directory 25 | **/build/ 26 | !**/src/**/build/ 27 | 28 | # Ignore local properties 29 | local.properties 30 | -------------------------------------------------------------------------------- /.idea/detekt.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /.idea/dictionaries/project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | konfeature 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/externalDependencies.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ### Changed 4 | 5 | - no changes 6 | 7 | ## v0.1.0 (2024-07-25) 8 | 9 | Initial public release 10 | 11 | [unreleased]: https://github.com/RedMadRobot/konfeature/compare/v0.1.0...main 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 red_mad_robot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Konfeature 2 | 3 | [![Version](https://img.shields.io/maven-central/v/com.redmadrobot.konfeature/konfeature?style=flat-square)][mavenCentral] 4 | [![Build Status](https://img.shields.io/github/actions/workflow/status/RedMadRobot/konfeature/main.yml?branch=main&style=flat-square)][ci] 5 | [![License](https://img.shields.io/github/license/RedMadRobot/Konfeature?style=flat-square)][license] 6 | 7 | 8 | Working with remote configuration has become a standard part of the development process for almost any application. Depending on the complexity of the application, several requirements for such functionality may arise, including: 9 | - convenient syntax for declaring configuration elements 10 | - the ability to separate configuration into different files for different features 11 | - the ability to make the configuration local-only during active feature development 12 | - support for multiple data sources for remote config 13 | - the ability to view a list of all configurations and modify their values for debugging purposes 14 | - logging the value and its source when accessing the configuration, as well as logging non-critical errors 15 | 16 | We have made every effort to meet all these requirements in the development of Konfeature. 17 | 18 | --- 19 | 20 | 21 | 22 | - [Installation](#installation) 23 | - [Usage](#usage) 24 | - [FeatureConfig](#featureconfig) 25 | - [FeatureSource](#featuresource) 26 | - [SourceSelectionStrategy](#sourceselectionstrategy) 27 | - [Interceptor](#interceptor) 28 | - [Logger](#logger) 29 | - [Spec](#spec) 30 | - [Ordering](#ordering) 31 | - [Contributing](#contributing) 32 | 33 | 34 | 35 | ## Installation 36 | 37 | Add the dependency: 38 | 39 | ```groovy 40 | repositories { 41 | mavenCentral() 42 | } 43 | 44 | dependencies { 45 | implementation("com.redmadrobot.konfeature:konfeature:") 46 | } 47 | ``` 48 | 49 | ## Usage 50 | 51 | ### FeatureConfig 52 | 53 | Defines a set of configuration elements, where each element is defined using a delegate. 54 | There are two types of delegates: 55 | - `by toggle(...)` - used for elements of type `Boolean` 56 | - `by value(...)` - used for elements of any other type 57 | 58 | ```kotlin 59 | class ProfileFeatureConfig : FeatureConfig( 60 | name = "profile_feature_config", 61 | description = "Config of features for profile usage" 62 | ) { 63 | val isProfileFeatureEnabled: Boolean by toggle( 64 | key = "profile_feature", 65 | description = "show profile entry point for user", 66 | defaultValue = false, 67 | ) 68 | 69 | val profileFeatureTitle: String by value( 70 | key = "profile_feature_title", 71 | description = "title of profile entry point button", 72 | defaultValue = "Feature number nine", 73 | sourceSelectionStrategy = SourceSelectionStrategy.Any 74 | ) 75 | 76 | val profileButtonAppearDuration: Long by value( 77 | key = "profile_button_appear_duration", 78 | description = "duration of profile button appearing in ms", 79 | defaultValue = 200, 80 | sourceSelectionStrategy = SourceSelectionStrategy.Any 81 | ) 82 | } 83 | ``` 84 | 85 | The configuration requires specifying: 86 | - `name` - the name of the configuration 87 | - `description` - a detailed description of the configuration 88 | 89 | Each configuration element requires specifying: 90 | - `key` - used to retrieve the value of the element from a `Source` 91 | - `description` - a detailed description of the element 92 | - `defaultValue` - used if the value cannot be found in a `Source` 93 | - `sourceSelectionStrategy` - the strategy for selecting a `Source` using [SourceSelectionStrategy](#sourceselectionstrategy) 94 | 95 | After that, you need to register the configuration in `Konfeature`: 96 | 97 | ```kotlin 98 | val profileFeatureConfig: FeatureConfig = ProfileFeatureConfig() 99 | 100 | val konfeatureInstance = konfeature { 101 | register(profileFeatureConfig) 102 | } 103 | ``` 104 | 105 | >Similarly, you can add multiple configurations, for example, for each module, when organizing multi-modularity by features. 106 | 107 | ### FeatureSource 108 | 109 | An abstraction over the value source for configuration elements. 110 | 111 | ```kotlin 112 | public interface FeatureSource { 113 | 114 | public val name: String 115 | 116 | public fun get(key: String): Any? 117 | } 118 | ``` 119 | - `name` - source name 120 | - `get(key: String)` - logic for getting values by `key` 121 | 122 | Example implementation based on `FirebaseRemoteConfig`: 123 | 124 | ```kotlin 125 | class FirebaseFeatureSource( 126 | private val remoteConfig: FirebaseRemoteConfig 127 | ) : FeatureSource { 128 | 129 | override val name: String = "FirebaseRemoteConfig" 130 | 131 | override fun get(key: String): Any? { 132 | return remoteConfig 133 | .getValue(key) 134 | .takeIf { source == FirebaseRemoteConfig.VALUE_SOURCE_REMOTE } 135 | ?.let { value: FirebaseRemoteConfigValue -> 136 | value.getOrNull { asBoolean() } 137 | ?: value.getOrNull { asString() } 138 | ?: value.getOrNull { asLong() } 139 | ?: value.getOrNull { asDouble() } 140 | } 141 | } 142 | 143 | private fun FirebaseRemoteConfigValue.getOrNull( 144 | getter: FirebaseRemoteConfigValue.() -> Any? 145 | ): Any? { 146 | return try { 147 | getter() 148 | } catch (error: IllegalArgumentException) { 149 | null 150 | } 151 | } 152 | } 153 | ``` 154 | After that, you need to add the `Source` in `Konfeature`: 155 | 156 | ```kotlin 157 | val profileFeatureConfig: FeatureConfig = ProfileFeatureConfig() 158 | val source: FeatureSource = FirebaseFeatureSource(remoteConfig) 159 | 160 | val konfeatureInstance = konfeature { 161 | addSource(source) 162 | register(profileFeatureConfig) 163 | } 164 | ``` 165 | 166 | >Similarly, you can add multiple sources, for example, Huawei AppGallery, RuStore, or your own backend. 167 | 168 | ### SourceSelectionStrategy 169 | 170 | You can configure the retrieval of an element's value from the source more flexibly by using the `sourceSelectionStrategy` parameter: 171 | 172 | ```kotlin 173 | val profileFeatureTitle: String by value( 174 | key = "profile_feature_title", 175 | description = "title of profile entry point button", 176 | defaultValue = "Feature number nine", 177 | sourceSelectionStrategy = SourceSelectionStrategy.Any 178 | ) 179 | ``` 180 | 181 | Where `sourceSelectionStrategy` filters the available data sources. 182 | 183 | ```kotlin 184 | public fun interface SourceSelectionStrategy { 185 | 186 | public fun select(names: Set): Set 187 | 188 | public companion object { 189 | public val None: SourceSelectionStrategy = SourceSelectionStrategy { emptySet() } 190 | public val Any: SourceSelectionStrategy = SourceSelectionStrategy { it } 191 | 192 | public fun anyOf(vararg sources: String): SourceSelectionStrategy = SourceSelectionStrategy { sources.toSet() } 193 | } 194 | } 195 | ``` 196 | 197 | The `select(...)` method receives a list of available `Source` names and returns a list of sources from which the configuration element can retrieve a value. 198 | 199 | For most scenarios, predefined implementations will be sufficient: 200 | - `SourceSelectionStrategy.None` - prohibits taking values from any source, i.e., the value specified in `defaultValue` will always be used 201 | - `SourceSelectionStrategy.Any` - allows taking values from any source 202 | - `SourceSelectionStrategy.anyOf("Source 1", ... ,"Source N")` - allows taking values from the specified list of sources 203 | 204 | > [!IMPORTANT] 205 | > By default, `SourceSelectionStrategy.None` is used! 206 | 207 | ### Interceptor 208 | 209 | Allows intercepting and overriding the value of the element. 210 | 211 | ```kotlin 212 | public interface Interceptor { 213 | 214 | public val name: String 215 | 216 | public fun intercept(valueSource: FeatureValueSource, key: String, value: Any): Any? 217 | } 218 | ``` 219 | 220 | - `name` - the name of the interceptor 221 | - `intercept(valueSource: FeatureValueSource, key: String, value: Any): Any?` - called when accessing the element with `key` and `value` from `valueSource(Source(), Interceptor(), Default)`, and returns its new value or `null` if it doesn't change 222 | 223 | Example of implementation based on `DebugPanelInterceptor`: 224 | 225 | ```kotlin 226 | class DebugPanelInterceptor : Interceptor { 227 | 228 | private val values = mutableMapOf() 229 | 230 | override val name: String = "DebugPanelInterceptor" 231 | 232 | override fun intercept(valueSource: FeatureValueSource, key: String, value: Any): Any? { 233 | return values[key] 234 | } 235 | 236 | fun setFeatureValue(key: String, value: Any) { 237 | values[key] = value 238 | } 239 | 240 | fun removeFeatureValue(key: String) { 241 | values.remove(key) 242 | } 243 | } 244 | ``` 245 | 246 | After that, you need to add the `Interceptor` in `Konfeature`: 247 | 248 | ```kotlin 249 | val profileFeatureConfig: FeatureConfig = ProfileFeatureConfig() 250 | val source: FeatureSource = FirebaseFeatureSource(remoteConfig) 251 | val debugPanelInterceptor: Interceptor = DebugPanelInterceptor() 252 | 253 | val konfeatureInstance = konfeature { 254 | addSource(source) 255 | register(profileFeatureConfig) 256 | addInterceptor(debugPanelInterceptor) 257 | } 258 | ``` 259 | 260 | >Similarly, you can add multiple interceptors. 261 | 262 | ### Logger 263 | 264 | ```kotlin 265 | public interface Logger { 266 | 267 | public fun log(severity: Severity, message: String) 268 | 269 | public enum class Severity { 270 | WARNING, INFO 271 | } 272 | } 273 | ``` 274 | 275 | The following events are logged: 276 | 277 | - key, value, and its source when requested 278 | >Get value 'true' by key 'profile_feature' from 'Source(name=FirebaseRemoteConfig)' 279 | - `Source` or `Interceptor` returns an unexpected type for `key` 280 | >Unexpected value type for 'profile_button_appear_duration': expected type is 'kotlin.Long', but value from 'Source(name=FirebaseRemoteConfig)' is 'true' with type 'kotlin.Boolean' 281 | 282 | Example of implementation based on `Timber`: 283 | 284 | ```kotlin 285 | class TimberLogger: Logger { 286 | 287 | override fun log(severity: Severity, message: String) { 288 | if (severity == INFO) { 289 | Timber.tag(TAG).i(message) 290 | } else if (severity == WARNING) { 291 | Timber.tag(TAG).w(message) 292 | } 293 | } 294 | 295 | companion object { 296 | private const val TAG = "Konfeature" 297 | } 298 | } 299 | ``` 300 | 301 | After that, you need to add the `Logger` in `Konfeature`: 302 | 303 | ```kotlin 304 | val profileFeatureConfig: FeatureConfig = ProfileFeatureConfig() 305 | val source: FeatureSource = FirebaseFeatureSource(remoteConfig) 306 | val debugPanelInterceptor: Interceptor = DebugPanelInterceptor() 307 | val logger: Logger = TimberLogger() 308 | 309 | val konfeatureInstance = konfeature { 310 | addSource(source) 311 | register(profileFeatureConfig) 312 | addInterceptor(debugPanelInterceptor) 313 | setLogger(logger) 314 | } 315 | ``` 316 | 317 | ### Spec 318 | 319 | Konfeature contains information about all registered `FeatureConfig` in the form of `spec`: 320 | 321 | ```kotlin 322 | public interface Konfeature { 323 | 324 | public val spec: List 325 | 326 | public fun getValue(spec: FeatureValueSpec): FeatureValue 327 | } 328 | ``` 329 | 330 | This allows you to obtain information about added configurations as well as the current value of each element: 331 | 332 | ```kotlin 333 | val konfeatureInstance = konfeature {...} 334 | 335 | val featureConfigSpec = konfeatureInstance.spec[0] 336 | val featureSpec = featureConfigSpec.values[0] 337 | val featureValue = konfeatureInstance.getValue(featureSpec) 338 | ``` 339 | > This can be useful for use in the DebugPanel 340 | 341 | ## Ordering 342 | The value of the configuration element is determined in the following order: 343 | 344 | - `defaultValue` and `Default` source are assigned. 345 | - Using `sourceSelectionStrategy`, a list of `Sources` from which a value can be requested is determined. 346 | - Search the list of `Sources` in the order they were added to `Konfeature`, **stopping at the first occurrence** of the element by `key`. 347 | Upon successful search, the value from `Source` is assigned with `Source(name=SourceName)` source. 348 | - Search the list of `Interceptors` in the order they were added to `Konfeature`. 349 | If `Interceptor` returns a value other than `null`, this value is assigned with `Interceptor(name=InterceptorName)` source. 350 | 351 | ## Contributing 352 | 353 | Merge requests are welcome. 354 | For major changes, please open an issue first to discuss what you would like to change. 355 | 356 | [mavenCentral]: https://central.sonatype.com/artifact/com.redmadrobot.konfeature/konfeature 357 | [ci]: https://github.com/RedMadRobot/Konfeature/actions?query=branch%3Amain 358 | [license]: ./LICENSE 359 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | 1. Run the script `release.sh` with the desired version as a parameter: 4 | ```bash 5 | ./release.sh [version] 6 | ```` 7 | 8 | 2. The script will ask you if you want to push the release branch and create a release tag. 9 | 10 | 3. Ensure `CHANGELOG.md` looks good and is ready to be published. 11 | 12 | 4. Type "yes" to the console if everything is okay. 13 | Tag push triggers a GitHub Actions workflow, 14 | which publishes the release artifacts to Maven Central and creates a GitHub release. 15 | 16 | 5. Click the link displayed in the console to create a Pull Request for release branch. 17 | 18 | 6. Merge the Pull Request as soon as the "Check" workflow succeeds. 19 | It is recommended to use fast-forward merge to merge release branches. 20 | 21 | ## Manual release preparation 22 | 23 | To prepare a release manually, follow the steps the script does: 24 | 25 | 1. Ensure the repository is up to date, and the main branch is checked out. 26 | 27 | 2. Create the release branch with the name `release/[version]`. 28 | 29 | 3. Update the version in `gradle.properties` and `README.md` ("Usage" section) with the version to be released. 30 | 31 | 4. Update the `CHANGELOG.md`: 32 | 1. Replace `Unreleased` section with the release version 33 | 2. Add a link to the diff between the previous and the new version 34 | 3. Add a new empty `Unreleased` section on the top 35 | 36 | 5. Commit the changes, create a tag on the latest commit, and push it to the remote repository. 37 | The tag should follow the format `v[version]`. 38 | 39 | 6. Create a Pull Request for the release branch and merge it. 40 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.infrastructure.detekt) 3 | alias(libs.plugins.versions) 4 | convention.detekt 5 | } 6 | -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | tasks.withType { 6 | kotlinOptions.jvmTarget = JavaVersion.VERSION_11.toString() 7 | } 8 | 9 | java { 10 | targetCompatibility = JavaVersion.VERSION_11 11 | sourceCompatibility = JavaVersion.VERSION_11 12 | } 13 | 14 | dependencies { 15 | implementation(libs.infrastructure.publish) 16 | implementation(libs.infrastructure.android) 17 | implementation(libs.publish.gradlePlugin) 18 | implementation(libs.gradle.android.cacheFixGradlePlugin) 19 | implementation(libs.kotlin.gradlePlugin) 20 | implementation(libs.detekt.gradlePlugin) 21 | implementation(libs.android.gradlePlugin) 22 | } 23 | -------------------------------------------------------------------------------- /buildSrc/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google { 5 | content { 6 | includeGroupAndSubgroups("com.android") 7 | includeGroupAndSubgroups("com.google") 8 | includeGroupAndSubgroups("androidx") 9 | } 10 | } 11 | mavenCentral() 12 | } 13 | } 14 | 15 | @Suppress("UnstableApiUsage") 16 | dependencyResolutionManagement { 17 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 18 | 19 | repositories { 20 | google { 21 | content { 22 | includeGroupAndSubgroups("com.android") 23 | includeGroupAndSubgroups("com.google") 24 | includeGroupAndSubgroups("androidx") 25 | } 26 | } 27 | 28 | mavenCentral() 29 | gradlePluginPortal() 30 | } 31 | 32 | versionCatalogs { 33 | create("libs") { 34 | from(files("../gradle/libs.versions.toml")) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/convention.detekt.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.gitlab.arturbosch.detekt.* 2 | 3 | plugins { 4 | id("io.gitlab.arturbosch.detekt") 5 | } 6 | 7 | tasks.withType().configureEach { 8 | jvmTarget = JavaVersion.VERSION_11.toString() 9 | } 10 | tasks.withType().configureEach { 11 | jvmTarget = JavaVersion.VERSION_11.toString() 12 | } 13 | 14 | dependencies { 15 | //noinspection UseTomlInstead 16 | detektPlugins("io.gitlab.arturbosch.detekt:detekt-rules-libraries:1.23.6") 17 | //noinspection UseTomlInstead 18 | detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.6") 19 | } 20 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/convention.library.android.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.redmadrobot.android-library") 3 | id("convention.publishing") 4 | id("convention.detekt") 5 | } 6 | 7 | redmadrobot { 8 | android.minSdk = 21 9 | } 10 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/convention.library.kotlin.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.redmadrobot.kotlin-library") 3 | id("convention.publishing") 4 | id("convention.detekt") 5 | } 6 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/convention.publishing.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.redmadrobot.build.dsl.* 2 | import com.vanniktech.maven.publish.SonatypeHost 3 | 4 | plugins { 5 | id("com.vanniktech.maven.publish") 6 | } 7 | 8 | mavenPublishing { 9 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true) 10 | signAllPublications() 11 | 12 | pom { 13 | name.convention(project.name) 14 | description.convention(project.description) 15 | 16 | licenses { 17 | mit() 18 | } 19 | 20 | developers { 21 | developer(id = "AleksandrTabolin", name = "Aleksandr Tabolin", email = "a.tabolin@redmadrobot.com") 22 | } 23 | 24 | setGitHubProject("RedMadRobot/Konfeature") 25 | } 26 | } 27 | 28 | publishing { 29 | repositories { 30 | if (isRunningOnCi) githubPackages("RedMadRobot/Konfeature") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /config/detekt/detekt.yml: -------------------------------------------------------------------------------- 1 | build: 2 | maxIssues: 0 3 | excludeCorrectable: false 4 | weights: 5 | complexity: 2 6 | style: 1 7 | comments: 1 8 | 9 | config: 10 | validation: true 11 | warningsAsErrors: true 12 | # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' 13 | excludes: '' 14 | 15 | processors: 16 | active: true 17 | exclude: 18 | - 'DetektProgressListener' 19 | # - 'KtFileCountProcessor' 20 | # - 'PackageCountProcessor' 21 | # - 'ClassCountProcessor' 22 | # - 'FunctionCountProcessor' 23 | # - 'PropertyCountProcessor' 24 | # - 'ProjectComplexityProcessor' 25 | # - 'ProjectCognitiveComplexityProcessor' 26 | # - 'ProjectLLOCProcessor' 27 | # - 'ProjectCLOCProcessor' 28 | # - 'ProjectLOCProcessor' 29 | # - 'ProjectSLOCProcessor' 30 | # - 'LicenseHeaderLoaderExtension' 31 | 32 | console-reports: 33 | active: true 34 | exclude: 35 | - 'ProjectStatisticsReport' 36 | - 'ComplexityReport' 37 | - 'NotificationReport' 38 | # - 'FindingsReport' 39 | - 'FileBasedFindingsReport' 40 | - 'LiteFindingsReport' 41 | 42 | output-reports: 43 | active: true 44 | exclude: [] 45 | # - 'TxtOutputReport' 46 | # - 'XmlOutputReport' 47 | # - 'HtmlOutputReport' 48 | 49 | comments: 50 | active: true 51 | AbsentOrWrongFileLicense: 52 | active: false 53 | licenseTemplateFile: 'license.template' 54 | licenseTemplateIsRegex: false 55 | CommentOverPrivateFunction: 56 | active: false 57 | CommentOverPrivateProperty: 58 | active: false 59 | DeprecatedBlockTag: 60 | active: false 61 | EndOfSentenceFormat: 62 | active: false 63 | endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' 64 | OutdatedDocumentation: 65 | active: false 66 | matchTypeParameters: true 67 | matchDeclarationsOrder: true 68 | UndocumentedPublicClass: 69 | active: false 70 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 71 | searchInNestedClass: true 72 | searchInInnerClass: true 73 | searchInInnerObject: true 74 | searchInInnerInterface: true 75 | UndocumentedPublicFunction: 76 | active: false 77 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 78 | UndocumentedPublicProperty: 79 | active: false 80 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 81 | 82 | complexity: 83 | active: true 84 | ComplexCondition: 85 | active: true 86 | threshold: 4 87 | ComplexInterface: 88 | active: false 89 | threshold: 10 90 | includeStaticDeclarations: false 91 | includePrivateDeclarations: false 92 | CognitiveComplexMethod: 93 | active: true 94 | threshold: 15 95 | LabeledExpression: 96 | active: false 97 | ignoredLabels: [] 98 | LargeClass: 99 | active: true 100 | threshold: 600 101 | LongMethod: 102 | active: true 103 | threshold: 60 104 | LongParameterList: 105 | active: true 106 | functionThreshold: 6 107 | constructorThreshold: 7 108 | ignoreDefaultParameters: true 109 | ignoreDataClasses: true 110 | ignoreAnnotatedParameter: [] 111 | MethodOverloading: 112 | active: false 113 | threshold: 6 114 | NamedArguments: 115 | active: true 116 | threshold: 3 117 | NestedBlockDepth: 118 | active: true 119 | threshold: 4 120 | ReplaceSafeCallChainWithRun: 121 | active: true 122 | StringLiteralDuplication: 123 | active: true 124 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 125 | threshold: 3 126 | ignoreAnnotation: true 127 | excludeStringsWithLessThan5Characters: true 128 | ignoreStringsRegex: '^(boolean|false)$' 129 | TooManyFunctions: 130 | active: true 131 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 132 | thresholdInFiles: 99 133 | thresholdInClasses: 11 134 | thresholdInInterfaces: 11 135 | thresholdInObjects: 11 136 | thresholdInEnums: 11 137 | ignoreDeprecated: false 138 | ignorePrivate: true 139 | ignoreOverridden: true 140 | 141 | coroutines: 142 | active: true 143 | GlobalCoroutineUsage: 144 | active: true 145 | InjectDispatcher: 146 | active: true 147 | dispatcherNames: 148 | - 'IO' 149 | - 'Default' 150 | - 'Unconfined' 151 | RedundantSuspendModifier: 152 | active: true 153 | SleepInsteadOfDelay: 154 | active: true 155 | SuspendFunWithFlowReturnType: 156 | active: true 157 | 158 | empty-blocks: 159 | active: true 160 | EmptyCatchBlock: 161 | active: true 162 | allowedExceptionNameRegex: '_|(ignore|expected).*' 163 | EmptyClassBlock: 164 | active: true 165 | EmptyDefaultConstructor: 166 | active: true 167 | EmptyDoWhileBlock: 168 | active: true 169 | EmptyElseBlock: 170 | active: true 171 | EmptyFinallyBlock: 172 | active: true 173 | EmptyForBlock: 174 | active: true 175 | EmptyFunctionBlock: 176 | active: true 177 | ignoreOverridden: false 178 | EmptyIfBlock: 179 | active: true 180 | EmptyInitBlock: 181 | active: true 182 | EmptyKtFile: 183 | active: true 184 | EmptySecondaryConstructor: 185 | active: true 186 | EmptyTryBlock: 187 | active: true 188 | EmptyWhenBlock: 189 | active: true 190 | EmptyWhileBlock: 191 | active: true 192 | 193 | exceptions: 194 | active: true 195 | ExceptionRaisedInUnexpectedLocation: 196 | active: true 197 | methodNames: 198 | - 'equals' 199 | - 'finalize' 200 | - 'hashCode' 201 | - 'toString' 202 | InstanceOfCheckForException: 203 | active: true 204 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 205 | NotImplementedDeclaration: 206 | active: true 207 | ObjectExtendsThrowable: 208 | active: true 209 | PrintStackTrace: 210 | active: true 211 | RethrowCaughtException: 212 | active: true 213 | ReturnFromFinally: 214 | active: true 215 | ignoreLabeled: false 216 | SwallowedException: 217 | active: true 218 | ignoredExceptionTypes: 219 | - 'InterruptedException' 220 | - 'MalformedURLException' 221 | - 'NumberFormatException' 222 | - 'ParseException' 223 | allowedExceptionNameRegex: '_|(ignore|expected).*' 224 | ThrowingExceptionFromFinally: 225 | active: true 226 | ThrowingExceptionInMain: 227 | active: false 228 | ThrowingExceptionsWithoutMessageOrCause: 229 | active: true 230 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 231 | exceptions: 232 | - 'ArrayIndexOutOfBoundsException' 233 | - 'Exception' 234 | - 'IllegalArgumentException' 235 | - 'IllegalMonitorStateException' 236 | - 'IllegalStateException' 237 | - 'IndexOutOfBoundsException' 238 | - 'NullPointerException' 239 | - 'RuntimeException' 240 | - 'Throwable' 241 | ThrowingNewInstanceOfSameException: 242 | active: true 243 | TooGenericExceptionCaught: 244 | active: true 245 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 246 | exceptionNames: 247 | - 'ArrayIndexOutOfBoundsException' 248 | - 'Error' 249 | - 'Exception' 250 | - 'IllegalMonitorStateException' 251 | - 'IndexOutOfBoundsException' 252 | - 'NullPointerException' 253 | - 'RuntimeException' 254 | - 'Throwable' 255 | allowedExceptionNameRegex: '_|(ignore|expected).*' 256 | TooGenericExceptionThrown: 257 | active: true 258 | exceptionNames: 259 | - 'Error' 260 | - 'Exception' 261 | - 'RuntimeException' 262 | - 'Throwable' 263 | 264 | formatting: 265 | active: true 266 | android: true 267 | autoCorrect: true 268 | AnnotationOnSeparateLine: 269 | active: false 270 | autoCorrect: true 271 | AnnotationSpacing: 272 | active: true 273 | autoCorrect: true 274 | ArgumentListWrapping: 275 | # TODO: Enable after update to ktlint 0.42+ 276 | # Issue: https://github.com/pinterest/ktlint/issues/1159 277 | active: false 278 | autoCorrect: true 279 | indentSize: 4 280 | maxLineLength: 120 281 | ChainWrapping: 282 | active: true 283 | autoCorrect: true 284 | CommentSpacing: 285 | active: true 286 | autoCorrect: true 287 | EnumEntryNameCase: 288 | active: false 289 | autoCorrect: true 290 | Filename: 291 | active: true 292 | FinalNewline: 293 | active: true 294 | autoCorrect: true 295 | insertFinalNewLine: true 296 | ImportOrdering: 297 | active: true 298 | autoCorrect: true 299 | layout: '*,java.**,javax.**,kotlin.**,^' 300 | Indentation: 301 | active: true 302 | autoCorrect: true 303 | indentSize: 4 304 | MaximumLineLength: 305 | active: false # See style.MaxLineLength 306 | maxLineLength: 120 307 | ignoreBackTickedIdentifier: true 308 | ModifierOrdering: 309 | active: true 310 | autoCorrect: true 311 | MultiLineIfElse: 312 | active: true 313 | autoCorrect: true 314 | NoBlankLineBeforeRbrace: 315 | active: true 316 | autoCorrect: true 317 | NoConsecutiveBlankLines: 318 | active: true 319 | autoCorrect: true 320 | NoEmptyClassBody: 321 | active: true 322 | autoCorrect: true 323 | NoEmptyFirstLineInMethodBlock: 324 | active: true 325 | autoCorrect: true 326 | NoLineBreakAfterElse: 327 | active: true 328 | autoCorrect: true 329 | NoLineBreakBeforeAssignment: 330 | active: true 331 | autoCorrect: true 332 | NoMultipleSpaces: 333 | active: true 334 | autoCorrect: true 335 | NoSemicolons: 336 | active: true 337 | autoCorrect: true 338 | NoTrailingSpaces: 339 | active: true 340 | autoCorrect: true 341 | NoUnitReturn: 342 | active: true 343 | autoCorrect: true 344 | NoUnusedImports: 345 | active: true 346 | autoCorrect: true 347 | NoWildcardImports: 348 | active: false 349 | PackageName: 350 | active: true 351 | autoCorrect: true 352 | ParameterListWrapping: 353 | active: true 354 | autoCorrect: true 355 | indentSize: 4 356 | maxLineLength: 120 357 | SpacingAroundAngleBrackets: 358 | active: true 359 | autoCorrect: true 360 | SpacingAroundColon: 361 | active: true 362 | autoCorrect: true 363 | SpacingAroundComma: 364 | active: true 365 | autoCorrect: true 366 | SpacingAroundCurly: 367 | active: true 368 | autoCorrect: true 369 | SpacingAroundDot: 370 | active: true 371 | autoCorrect: true 372 | SpacingAroundDoubleColon: 373 | active: true 374 | autoCorrect: true 375 | SpacingAroundKeyword: 376 | active: true 377 | autoCorrect: true 378 | SpacingAroundOperators: 379 | active: true 380 | autoCorrect: true 381 | SpacingAroundParens: 382 | active: true 383 | autoCorrect: true 384 | SpacingAroundRangeOperator: 385 | active: true 386 | autoCorrect: true 387 | SpacingAroundUnaryOperator: 388 | active: true 389 | autoCorrect: true 390 | SpacingBetweenDeclarationsWithAnnotations: 391 | active: true 392 | autoCorrect: true 393 | SpacingBetweenDeclarationsWithComments: 394 | active: true 395 | autoCorrect: true 396 | StringTemplate: 397 | active: true 398 | autoCorrect: true 399 | 400 | naming: 401 | active: true 402 | BooleanPropertyNaming: 403 | active: true 404 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 405 | allowedPattern: '^(is|has|are)' 406 | ClassNaming: 407 | active: true 408 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 409 | classPattern: '[A-Z][a-zA-Z0-9]*' 410 | ConstructorParameterNaming: 411 | active: true 412 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 413 | parameterPattern: '[a-z][A-Za-z0-9]*' 414 | privateParameterPattern: '[a-z][A-Za-z0-9]*' 415 | excludeClassPattern: '$^' 416 | EnumNaming: 417 | active: true 418 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 419 | enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' 420 | ForbiddenClassName: 421 | active: false 422 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 423 | forbiddenName: [] 424 | FunctionMaxLength: 425 | active: false 426 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 427 | maximumFunctionNameLength: 30 428 | FunctionMinLength: 429 | active: false 430 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 431 | minimumFunctionNameLength: 3 432 | FunctionNaming: 433 | active: true 434 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 435 | functionPattern: '([a-z][a-zA-Z0-9]*)|(`.*`)' 436 | ignoreAnnotated: ['Composable'] 437 | excludeClassPattern: '$^' 438 | FunctionParameterNaming: 439 | active: true 440 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 441 | parameterPattern: '[a-z][A-Za-z0-9]*' 442 | excludeClassPattern: '$^' 443 | InvalidPackageDeclaration: 444 | active: false 445 | rootPackage: '' 446 | LambdaParameterNaming: 447 | active: true 448 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 449 | parameterPattern: '[a-z][A-Za-z0-9]*|_' 450 | MatchingDeclarationName: 451 | active: true 452 | mustBeFirst: true 453 | MemberNameEqualsClassName: 454 | active: true 455 | ignoreOverridden: true 456 | NoNameShadowing: 457 | active: true 458 | NonBooleanPropertyPrefixedWithIs: 459 | active: true 460 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 461 | ObjectPropertyNaming: 462 | active: true 463 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 464 | constantPattern: '[A-Za-z][_A-Za-z0-9]*' 465 | propertyPattern: '[A-Za-z][_A-Za-z0-9]*' 466 | privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' 467 | PackageNaming: 468 | active: true 469 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 470 | packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' 471 | TopLevelPropertyNaming: 472 | active: true 473 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 474 | constantPattern: '[A-Z][_A-Z0-9]*' 475 | propertyPattern: '[A-Za-z][_A-Za-z0-9]*' 476 | privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' 477 | VariableMaxLength: 478 | active: false 479 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 480 | maximumVariableNameLength: 64 481 | VariableMinLength: 482 | active: false 483 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 484 | minimumVariableNameLength: 1 485 | VariableNaming: 486 | active: true 487 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 488 | variablePattern: '[a-z][A-Za-z0-9]*' 489 | privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' 490 | excludeClassPattern: '$^' 491 | 492 | performance: 493 | active: true 494 | ArrayPrimitive: 495 | active: true 496 | ForEachOnRange: 497 | active: true 498 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 499 | SpreadOperator: 500 | active: true 501 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 502 | UnnecessaryTemporaryInstantiation: 503 | active: true 504 | 505 | potential-bugs: 506 | active: true 507 | AvoidReferentialEquality: 508 | active: true 509 | forbiddenTypePatterns: 510 | - 'kotlin.String' 511 | CastToNullableType: 512 | active: true 513 | Deprecation: 514 | active: false 515 | DontDowncastCollectionTypes: 516 | active: true 517 | DoubleMutabilityForCollection: 518 | active: true 519 | EqualsAlwaysReturnsTrueOrFalse: 520 | active: true 521 | EqualsWithHashCodeExist: 522 | active: true 523 | ExitOutsideMain: 524 | active: true 525 | ExplicitGarbageCollectionCall: 526 | active: true 527 | HasPlatformType: 528 | active: true 529 | IgnoredReturnValue: 530 | active: true 531 | restrictToConfig: true 532 | returnValueAnnotations: 533 | - '*.CheckResult' 534 | - '*.CheckReturnValue' 535 | ignoreReturnValueAnnotations: 536 | - '*.CanIgnoreReturnValue' 537 | ImplicitDefaultLocale: 538 | active: true 539 | ImplicitUnitReturnType: 540 | active: false 541 | allowExplicitReturnType: true 542 | InvalidRange: 543 | active: true 544 | IteratorHasNextCallsNextMethod: 545 | active: true 546 | IteratorNotThrowingNoSuchElementException: 547 | active: true 548 | LateinitUsage: 549 | active: false 550 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 551 | ignoreOnClassesPattern: '' 552 | MapGetWithNotNullAssertionOperator: 553 | active: true 554 | MissingPackageDeclaration: 555 | active: true 556 | excludes: ['**/*.kts'] 557 | NullableToStringCall: 558 | active: true 559 | UnconditionalJumpStatementInLoop: 560 | active: true 561 | UnnecessaryNotNullOperator: 562 | active: true 563 | UnnecessarySafeCall: 564 | active: true 565 | UnreachableCatchBlock: 566 | active: true 567 | UnreachableCode: 568 | active: true 569 | UnsafeCallOnNullableType: 570 | active: true 571 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 572 | UnsafeCast: 573 | active: true 574 | UnusedUnaryOperator: 575 | active: true 576 | UselessPostfixExpression: 577 | active: true 578 | WrongEqualsTypeParameter: 579 | active: true 580 | 581 | style: 582 | active: true 583 | BracesOnWhenStatements: 584 | active: false 585 | ClassOrdering: 586 | active: true 587 | CollapsibleIfStatements: 588 | active: true 589 | DataClassContainsFunctions: 590 | active: false 591 | conversionFunctionPrefix: ['to'] 592 | DataClassShouldBeImmutable: 593 | active: true 594 | DestructuringDeclarationWithTooManyEntries: 595 | active: true 596 | maxDestructuringEntries: 3 597 | EqualsNullCall: 598 | active: true 599 | EqualsOnSignatureLine: 600 | active: true 601 | ExplicitCollectionElementAccessMethod: 602 | active: true 603 | ExplicitItLambdaParameter: 604 | active: true 605 | # TODO: https://github.com/detekt/detekt/issues/950#issuecomment-401575701 606 | ExpressionBodySyntax: 607 | active: false 608 | includeLineWrapping: false 609 | ForbiddenComment: 610 | active: true 611 | comments: 612 | - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' 613 | value: 'FIXME:' 614 | - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' 615 | value: 'STOPSHIP:' 616 | allowedPatterns: '' 617 | ForbiddenImport: 618 | active: false 619 | imports: [] 620 | forbiddenPatterns: '' 621 | ForbiddenMethodCall: 622 | active: true 623 | methods: 624 | - 'kotlin.io.print' 625 | - 'kotlin.io.println' 626 | ForbiddenVoid: 627 | active: true 628 | ignoreOverridden: false 629 | ignoreUsageInGenerics: false 630 | FunctionOnlyReturningConstant: 631 | active: true 632 | ignoreOverridableFunction: true 633 | ignoreActualFunction: true 634 | excludedFunctions: [''] 635 | LoopWithTooManyJumpStatements: 636 | active: true 637 | maxJumpCount: 1 638 | MagicNumber: 639 | active: true 640 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts'] 641 | ignoreNumbers: 642 | - '-1' 643 | - '0' 644 | - '0.5' 645 | - '1' 646 | - '2' 647 | ignoreHashCodeFunction: true 648 | ignorePropertyDeclaration: true 649 | ignoreLocalVariableDeclaration: false 650 | ignoreConstantDeclaration: true 651 | ignoreCompanionObjectPropertyDeclaration: true 652 | ignoreAnnotation: false 653 | ignoreNamedArgument: true 654 | ignoreEnums: false 655 | ignoreRanges: false 656 | ignoreExtensionFunctions: true 657 | MandatoryBracesLoops: 658 | active: true 659 | MaxLineLength: 660 | active: true 661 | maxLineLength: 120 662 | excludePackageStatements: true 663 | excludeImportStatements: true 664 | excludeCommentStatements: true 665 | MayBeConst: 666 | active: true 667 | ModifierOrder: 668 | active: true 669 | MultilineLambdaItParameter: 670 | active: true 671 | NestedClassesVisibility: 672 | active: true 673 | NewLineAtEndOfFile: 674 | active: true 675 | NoTabs: 676 | active: true 677 | ObjectLiteralToLambda: 678 | active: true 679 | OptionalAbstractKeyword: 680 | active: true 681 | OptionalUnit: 682 | active: true 683 | PreferToOverPairSyntax: 684 | active: true 685 | ProtectedMemberInFinalClass: 686 | active: true 687 | RedundantExplicitType: 688 | active: true 689 | RedundantHigherOrderMapUsage: 690 | active: true 691 | RedundantVisibilityModifierRule: 692 | active: false # Conflicts with explicit API mode 693 | ReturnCount: 694 | active: true 695 | max: 2 696 | excludedFunctions: ['equals'] 697 | excludeLabeled: false 698 | excludeReturnFromLambda: true 699 | excludeGuardClauses: false 700 | SafeCast: 701 | active: true 702 | SerialVersionUIDInSerializableClass: 703 | active: true 704 | SpacingBetweenPackageAndImports: 705 | active: false # See formatting.NoConsecutiveBlankLines 706 | ThrowsCount: 707 | active: true 708 | max: 2 709 | excludeGuardClauses: false 710 | TrailingWhitespace: 711 | active: false # See formatting.NoTrailingWhitespace 712 | UnderscoresInNumericLiterals: 713 | active: true 714 | acceptableLength: 5 715 | UnnecessaryAbstractClass: 716 | active: true 717 | UnnecessaryAnnotationUseSiteTarget: 718 | active: true 719 | UnnecessaryApply: 720 | active: true 721 | UnnecessaryFilter: 722 | active: true 723 | UnnecessaryInheritance: 724 | active: true 725 | UnnecessaryLet: 726 | active: true 727 | UnnecessaryParentheses: 728 | active: true 729 | UntilInsteadOfRangeTo: 730 | active: true 731 | UnusedImports: 732 | active: false # See formatting.NoUnusedImports 733 | UnusedPrivateClass: 734 | active: true 735 | UnusedPrivateMember: 736 | active: true 737 | allowedNames: '(_|ignored|expected|serialVersionUID)' 738 | UseAnyOrNoneInsteadOfFind: 739 | active: true 740 | UseArrayLiteralsInAnnotations: 741 | active: true 742 | UseCheckNotNull: 743 | active: true 744 | UseCheckOrError: 745 | active: true 746 | UseDataClass: 747 | active: false 748 | allowVars: false 749 | UseEmptyCounterpart: 750 | active: true 751 | UseIfEmptyOrIfBlank: 752 | active: true 753 | UseIfInsteadOfWhen: 754 | active: true 755 | UseIsNullOrEmpty: 756 | active: true 757 | UseOrEmpty: 758 | active: true 759 | UseRequire: 760 | active: true 761 | UseRequireNotNull: 762 | active: true 763 | UselessCallOnNotNull: 764 | active: true 765 | UtilityClassWithPublicConstructor: 766 | active: true 767 | VarCouldBeVal: 768 | active: true 769 | WildcardImport: 770 | active: false 771 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 772 | excludeImports: 773 | - 'java.util.*' 774 | 775 | libraries: 776 | ForbiddenPublicDataClass: 777 | active: true 778 | excludes: [ '**' ] 779 | ignorePackages: 780 | - '*.internal' 781 | - '*.internal.*' 782 | LibraryCodeMustSpecifyReturnType: 783 | active: true 784 | excludes: [ '**' ] 785 | LibraryEntitiesShouldNotBePublic: 786 | active: true 787 | excludes: [ '**' ] 788 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | group=com.redmadrobot.konfeature 3 | version=0.1.0 4 | 5 | # IDE (e.g. Android Studio) users: 6 | # Gradle settings configured through the IDE *will override* 7 | # any settings specified in this file. 8 | 9 | # For more details on how to configure your build environment visit 10 | # http://www.gradle.org/docs/current/userguide/build_environment.html 11 | 12 | # Specifies the JVM arguments used for the daemon process. 13 | # The setting is particularly useful for tweaking memory settings. 14 | # Default value: -Xmx1024m -XX:MaxPermSize=256m 15 | org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8 16 | 17 | # When configured, Gradle will run in incubating parallel mode. 18 | # This option should only be used with decoupled projects. More details, visit 19 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 20 | org.gradle.parallel=true 21 | 22 | # Gradle will try to reuse outputs from previous builds for all builds, unless 23 | # explicitly disabled with --no-build-cache. 24 | # https://docs.gradle.org/current/userguide/build_cache.html 25 | org.gradle.caching=true 26 | 27 | # To detect changes on the file-system, and to calculate what needs to be rebuilt, Gradle collects 28 | # information about the file-system in-memory during every build (aka Virtual File-System). 29 | # https://docs.gradle.org/current/userguide/gradle_daemon.html#sec:daemon_watch_fs 30 | org.gradle.vfs.watch=true 31 | #org.gradle.vfs.verbose=true 32 | 33 | # Notify Android Gradle Plugin that we use AndroidX and not use Jetifier 34 | android.useAndroidX=true 35 | android.enableJetifier=false 36 | 37 | # Disable Android Studio version check to be able to use Intellij IDEA. 38 | android.injected.studio.version.check=false 39 | 40 | # Use official kotlin code style 41 | kotlin.code.style=official 42 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | # For an example of how to maintain dependencies in version catalogs, 2 | # see https://github.com/RedMadRobot/gradle-version-catalogs. 3 | [versions] 4 | activity = "1.9.0" 5 | android-gradle-plugin = "8.4.0" 6 | detekt = "1.23.6" 7 | gradle-android-cacheFix = "3.0.1" 8 | gradle-infrastructure = "0.18.1" 9 | kotlin = "2.0.0" 10 | versionsPlugin = "0.51.0" 11 | publish-plugin = "0.28.0" 12 | poko = "0.16.0" 13 | kotest = "5.9.1" 14 | 15 | [libraries] 16 | android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "android-gradle-plugin" } 17 | detekt-gradlePlugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } 18 | gradle-android-cacheFixGradlePlugin = { module = "gradle.plugin.org.gradle.android:android-cache-fix-gradle-plugin", version.ref = "gradle-android-cacheFix" } 19 | infrastructure-android = { module = "com.redmadrobot.build:infrastructure-android", version.ref = "gradle-infrastructure" } 20 | infrastructure-publish = { module = "com.redmadrobot.build:infrastructure-publish", version.ref = "gradle-infrastructure" } 21 | publish-gradlePlugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "publish-plugin" } 22 | kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } 23 | kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } 24 | 25 | [plugins] 26 | infrastructure-detekt = { id = "com.redmadrobot.detekt", version.ref = "gradle-infrastructure" } 27 | versions = { id = "com.github.ben-manes.versions", version.ref = "versionsPlugin" } 28 | poko = { id = "dev.drewhamilton.poko", version.ref = "poko" } 29 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedMadRobot/Konfeature/8e01fc7511ecefd3d16563cb0025aca541b4c1e0/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /konfeature/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("multiplatform") 3 | alias(libs.plugins.poko) 4 | convention.publishing 5 | convention.detekt 6 | } 7 | 8 | description = "Kotlin library for working with feature remote configuration" 9 | 10 | kotlin { 11 | explicitApi() 12 | jvm() 13 | 14 | sourceSets { 15 | commonMain.dependencies { 16 | api(kotlin("stdlib")) 17 | } 18 | commonTest.dependencies { 19 | implementation(kotlin("test")) 20 | implementation(libs.kotest.assertions.core) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/FeatureConfig.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature 2 | 3 | import com.redmadrobot.konfeature.source.SourceSelectionStrategy 4 | import kotlin.properties.ReadOnlyProperty 5 | import kotlin.reflect.KProperty 6 | 7 | public abstract class FeatureConfig( 8 | override val name: String, 9 | override val description: String 10 | ) : FeatureConfigSpec { 11 | private var konfeature: Konfeature? = null 12 | private val _values = mutableListOf>() 13 | 14 | override val values: List> 15 | get() = _values.toList() 16 | 17 | internal fun bind(konfeature: Konfeature) { 18 | this.konfeature = konfeature 19 | } 20 | 21 | @JvmName("ValueBoolean") 22 | @Deprecated( 23 | message = "Use toggle instead", 24 | replaceWith = ReplaceWith("toggle(key, description, defaultValue, sourceSelectionStrategy)"), 25 | level = DeprecationLevel.ERROR, 26 | ) 27 | @Suppress("UNUSED_PARAMETER", "FINAL_UPPER_BOUND") 28 | public fun value( 29 | key: String, 30 | description: String, 31 | defaultValue: T, 32 | sourceSelectionStrategy: SourceSelectionStrategy = SourceSelectionStrategy.None 33 | ): ReadOnlyProperty { 34 | error("Use toggle instead of boolean value") 35 | } 36 | 37 | public fun value( 38 | key: String, 39 | description: String, 40 | defaultValue: T, 41 | sourceSelectionStrategy: SourceSelectionStrategy = SourceSelectionStrategy.None 42 | ): ReadOnlyProperty { 43 | return createValue( 44 | key = key, 45 | description = description, 46 | defaultValue = defaultValue, 47 | sourceSelectionStrategy = sourceSelectionStrategy 48 | ) 49 | } 50 | 51 | public fun toggle( 52 | key: String, 53 | description: String, 54 | defaultValue: Boolean, 55 | sourceSelectionStrategy: SourceSelectionStrategy = SourceSelectionStrategy.None, 56 | ): ReadOnlyProperty { 57 | return createValue( 58 | key = key, 59 | description = description, 60 | defaultValue = defaultValue, 61 | sourceSelectionStrategy = sourceSelectionStrategy 62 | ) 63 | } 64 | 65 | private fun createValue( 66 | key: String, 67 | description: String, 68 | defaultValue: T, 69 | sourceSelectionStrategy: SourceSelectionStrategy = SourceSelectionStrategy.None 70 | ): ReadOnlyProperty { 71 | val spec = FeatureValueSpec( 72 | key = key, 73 | description = description, 74 | defaultValue = defaultValue, 75 | sourceSelectionStrategy = sourceSelectionStrategy 76 | ) 77 | _values.add(spec) 78 | return Value(spec) 79 | } 80 | 81 | private class Value( 82 | private val spec: FeatureValueSpec, 83 | ) : ReadOnlyProperty { 84 | override fun getValue(thisRef: FeatureConfig?, property: KProperty<*>): T { 85 | return checkBinding(thisRef?.konfeature).getValue(spec).value 86 | } 87 | 88 | private fun checkBinding(konFeature: Konfeature?): Konfeature { 89 | return checkNotNull(konFeature) { "FeatureConfig is not bound to Konfeature" } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/FeatureConfigSpec.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature 2 | 3 | public interface FeatureConfigSpec { 4 | public val name: String 5 | public val description: String 6 | public val values: List> 7 | } 8 | -------------------------------------------------------------------------------- /konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/FeatureValue.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature 2 | 3 | import com.redmadrobot.konfeature.source.FeatureValueSource 4 | import dev.drewhamilton.poko.Poko 5 | 6 | @Poko 7 | public class FeatureValue( 8 | public val source: FeatureValueSource, 9 | public val value: T, 10 | ) 11 | -------------------------------------------------------------------------------- /konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/FeatureValueSpec.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature 2 | 3 | import com.redmadrobot.konfeature.source.SourceSelectionStrategy 4 | import dev.drewhamilton.poko.Poko 5 | 6 | @Poko 7 | public class FeatureValueSpec( 8 | public val key: String, 9 | public val description: String, 10 | public val defaultValue: T, 11 | public val sourceSelectionStrategy: SourceSelectionStrategy 12 | ) 13 | -------------------------------------------------------------------------------- /konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/Konfeature.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature 2 | 3 | public interface Konfeature { 4 | 5 | public val spec: List 6 | 7 | public fun getValue(spec: FeatureValueSpec): FeatureValue 8 | } 9 | -------------------------------------------------------------------------------- /konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/Logger.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature 2 | 3 | public interface Logger { 4 | 5 | public fun log(severity: Severity, message: String) 6 | 7 | public enum class Severity { 8 | WARNING, INFO 9 | } 10 | } 11 | 12 | internal fun Logger.logWarn(message: String) { 13 | log(Logger.Severity.WARNING, message) 14 | } 15 | 16 | internal fun Logger.logInfo(message: String) { 17 | log(Logger.Severity.INFO, message) 18 | } 19 | -------------------------------------------------------------------------------- /konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/builder/KonfeatureBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature.builder 2 | 3 | import com.redmadrobot.konfeature.* 4 | import com.redmadrobot.konfeature.exception.ConfigNameAlreadyExistException 5 | import com.redmadrobot.konfeature.exception.KeyDuplicationException 6 | import com.redmadrobot.konfeature.exception.NoFeatureConfigException 7 | import com.redmadrobot.konfeature.exception.SourceNameAlreadyExistException 8 | import com.redmadrobot.konfeature.source.FeatureSource 9 | import com.redmadrobot.konfeature.source.Interceptor 10 | 11 | public class KonfeatureBuilder { 12 | private val sources = mutableListOf() 13 | private var interceptors = mutableListOf() 14 | private var spec = mutableListOf() 15 | private var logger: Logger? = null 16 | 17 | public fun addInterceptor(interceptor: Interceptor): KonfeatureBuilder { 18 | interceptors.add(interceptor) 19 | return this 20 | } 21 | 22 | public fun addSource(source: FeatureSource): KonfeatureBuilder { 23 | if (sources.any { it.name == source.name }) { 24 | throw SourceNameAlreadyExistException(source.name) 25 | } 26 | 27 | sources.add(source) 28 | return this 29 | } 30 | 31 | public fun register(featureConfig: FeatureConfig): KonfeatureBuilder { 32 | if (spec.any { it.name == featureConfig.name }) { 33 | throw ConfigNameAlreadyExistException(featureConfig.name) 34 | } 35 | spec.add(featureConfig) 36 | return this 37 | } 38 | 39 | public fun setLogger(logger: Logger): KonfeatureBuilder { 40 | this.logger = logger 41 | return this 42 | } 43 | 44 | public fun build(): Konfeature { 45 | if (spec.isEmpty()) throw NoFeatureConfigException() 46 | 47 | spec.forEach(::validateConfigSpec) 48 | 49 | return KonfeatureImpl( 50 | sources = sources, 51 | interceptors = interceptors, 52 | logger = logger, 53 | spec = spec 54 | ).also { toggleEase -> 55 | spec.forEach { values -> 56 | values.bind(toggleEase) 57 | } 58 | } 59 | } 60 | 61 | private fun validateConfigSpec(config: FeatureConfigSpec) { 62 | val counter = mutableMapOf().withDefault { 0 } 63 | var hasDuplicates = false 64 | config.values.forEach { valueSpec -> 65 | val value = counter.getValue(valueSpec.key) 66 | if (value > 0) { 67 | hasDuplicates = true 68 | } 69 | counter[valueSpec.key] = value + 1 70 | } 71 | 72 | if (hasDuplicates) { 73 | val values = counter.asSequence() 74 | .filter { it.value > 1 } 75 | .map { it.key } 76 | .toList() 77 | throw KeyDuplicationException(values, config.name) 78 | } else if (counter.isEmpty()) { 79 | logger?.logWarn("Config '${config.name}' is empty") 80 | } 81 | } 82 | } 83 | 84 | public fun konfeature(build: KonfeatureBuilder.() -> Unit): Konfeature { 85 | return KonfeatureBuilder().apply(build).build() 86 | } 87 | -------------------------------------------------------------------------------- /konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/builder/KonfeatureImpl.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("NoWildcardImports", "WildcardImport") 2 | 3 | package com.redmadrobot.konfeature.builder 4 | 5 | import com.redmadrobot.konfeature.* 6 | import com.redmadrobot.konfeature.FeatureConfigSpec 7 | import com.redmadrobot.konfeature.FeatureValueSpec 8 | import com.redmadrobot.konfeature.source.FeatureSource 9 | import com.redmadrobot.konfeature.source.FeatureValueSource 10 | import com.redmadrobot.konfeature.source.Interceptor 11 | import kotlin.reflect.KClass 12 | 13 | internal class KonfeatureImpl( 14 | private val sources: List, 15 | private val interceptors: List, 16 | private val logger: Logger?, 17 | override val spec: List, 18 | ) : Konfeature { 19 | 20 | private val sourcesNames = sources.map { it.name }.toSet() 21 | 22 | @Suppress("LoopWithTooManyJumpStatements") 23 | override fun getValue(spec: FeatureValueSpec): FeatureValue { 24 | val selectedSourcesNames = spec.sourceSelectionStrategy.select(sourcesNames) 25 | 26 | val expectedClass = spec.defaultValue::class 27 | var value: T = spec.defaultValue 28 | var valueSource: FeatureValueSource = FeatureValueSource.Default 29 | 30 | for (source in sources) { 31 | if (source.name !in selectedSourcesNames) continue 32 | val actualSourceValue = source.get(spec.key) 33 | val sourceValue = expectedClass.tryCastOrNull(actualSourceValue) 34 | 35 | if (actualSourceValue != null && sourceValue == null) { 36 | logger?.logUnexpectedValueType( 37 | key = spec.key, 38 | source = FeatureValueSource.Source(source.name), 39 | value = actualSourceValue, 40 | actualClass = actualSourceValue::class.qualifiedName, 41 | expectedClass = expectedClass.qualifiedName, 42 | ) 43 | } 44 | 45 | if (sourceValue != null) { 46 | value = sourceValue 47 | valueSource = FeatureValueSource.Source(source.name) 48 | break 49 | } 50 | } 51 | 52 | for (interceptor in interceptors) { 53 | val actualInterceptorValue = interceptor.intercept(valueSource, spec.key, value) 54 | val interceptorValue = expectedClass.tryCastOrNull(actualInterceptorValue) 55 | 56 | if (actualInterceptorValue != null && interceptorValue == null) { 57 | logger?.logUnexpectedValueType( 58 | key = spec.key, 59 | source = FeatureValueSource.Interceptor(interceptor.name), 60 | value = actualInterceptorValue, 61 | actualClass = actualInterceptorValue::class.qualifiedName, 62 | expectedClass = expectedClass.qualifiedName, 63 | ) 64 | } 65 | 66 | if (interceptorValue != null) { 67 | value = interceptorValue 68 | valueSource = FeatureValueSource.Interceptor(interceptor.name) 69 | } 70 | } 71 | 72 | logger?.logInfo("Get value '$value' by key '${spec.key}' from '$valueSource'") 73 | 74 | return FeatureValue(valueSource, value) 75 | } 76 | 77 | private fun Logger.logUnexpectedValueType( 78 | key: String, 79 | source: FeatureValueSource, 80 | value: Any, 81 | actualClass: String?, 82 | expectedClass: String?, 83 | ) { 84 | logWarn( 85 | "Unexpected value type for '$key': " + 86 | "expected type is '$expectedClass', but " + 87 | "value from '$source' " + 88 | "is '$value' " + 89 | "with type '$actualClass'", 90 | ) 91 | } 92 | 93 | @Suppress("UNCHECKED_CAST") 94 | private fun KClass.tryCastOrNull(value: Any?): T? { 95 | return if (isInstance(value)) value as T else null 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/exception/KonfeatureException.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature.exception 2 | 3 | public sealed class KonfeatureException(messageProvider: () -> String) : Exception(messageProvider.invoke()) 4 | 5 | public class ConfigNameAlreadyExistException( 6 | name: String 7 | ) : KonfeatureException({ "feature config with name '$name' already registered" }) 8 | 9 | public class KeyDuplicationException( 10 | values: List, 11 | config: String 12 | ) : KonfeatureException({ 13 | val duplicatedValues = values.joinToString(separator = ", ", transform = { "'$it'" }) 14 | "values with keys <$duplicatedValues> are duplicated in config '$config'" 15 | }) 16 | 17 | public class NoFeatureConfigException : KonfeatureException({ "No feature config added" }) 18 | 19 | public class SourceNameAlreadyExistException( 20 | name: String 21 | ) : KonfeatureException({ "source with name '$name' already registered" }) 22 | -------------------------------------------------------------------------------- /konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/source/FeatureSource.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature.source 2 | 3 | public interface FeatureSource { 4 | 5 | public val name: String 6 | 7 | public fun get(key: String): Any? 8 | } 9 | -------------------------------------------------------------------------------- /konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/source/FeatureValueSource.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature.source 2 | 3 | import dev.drewhamilton.poko.Poko 4 | 5 | public sealed class FeatureValueSource { 6 | 7 | @Poko 8 | public class Source(public val name: String) : FeatureValueSource() 9 | 10 | @Poko 11 | public class Interceptor(public val name: String) : FeatureValueSource() 12 | 13 | @Suppress("ConvertObjectToDataObject") 14 | public object Default : FeatureValueSource() { 15 | override fun toString(): String = "Default" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/source/Interceptor.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature.source 2 | 3 | public interface Interceptor { 4 | 5 | public val name: String 6 | 7 | public fun intercept(valueSource: FeatureValueSource, key: String, value: Any): Any? 8 | } 9 | -------------------------------------------------------------------------------- /konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/source/SourceSelectionStrategy.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature.source 2 | 3 | public fun interface SourceSelectionStrategy { 4 | 5 | public fun select(names: Set): Set 6 | 7 | public companion object { 8 | public val None: SourceSelectionStrategy = SourceSelectionStrategy { emptySet() } 9 | public val Any: SourceSelectionStrategy = SourceSelectionStrategy { it } 10 | 11 | public fun anyOf(vararg sources: String): SourceSelectionStrategy = SourceSelectionStrategy { sources.toSet() } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /konfeature/src/commonTest/kotlin/com/redmadrobot/konfeature/KonfeatureTest.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature 2 | 3 | import com.redmadrobot.konfeature.builder.konfeature 4 | import com.redmadrobot.konfeature.helper.TestFeatureConfig 5 | import com.redmadrobot.konfeature.helper.createTestSource 6 | import com.redmadrobot.konfeature.source.FeatureValueSource 7 | import com.redmadrobot.konfeature.source.Interceptor 8 | import com.redmadrobot.konfeature.source.SourceSelectionStrategy 9 | import io.kotest.assertions.assertSoftly 10 | import io.kotest.matchers.shouldBe 11 | import kotlin.test.Test 12 | 13 | class KonfeatureTest { 14 | 15 | @Test 16 | fun `when correct config added - then code should pass`() { 17 | val featureConfig = TestFeatureConfig() 18 | 19 | konfeature { 20 | register(featureConfig) 21 | } 22 | } 23 | 24 | @Test 25 | fun `when correct config added - should be correct spec`() { 26 | // GIVEN 27 | val sourceNames = listOf("Test Source 1", "Test Source 2", "Test Source 3") 28 | val selectedSource = sourceNames[2] 29 | 30 | val featureConfig = TestFeatureConfig( 31 | cSourceSelectionStrategy = SourceSelectionStrategy.anyOf(selectedSource), 32 | ) 33 | 34 | // WHEN 35 | val toggleEase = konfeature { 36 | register(featureConfig) 37 | } 38 | 39 | // THEN 40 | toggleEase.spec.size shouldBe 1 41 | 42 | val config = toggleEase.spec.first() 43 | 44 | config.name shouldBe featureConfig.name 45 | config.description shouldBe featureConfig.description 46 | config.values.size shouldBe 3 47 | 48 | assertSoftly(config) { 49 | values[0].apply { 50 | key shouldBe "a" 51 | description shouldBe "feature a desc" 52 | defaultValue shouldBe true 53 | sourceSelectionStrategy.select(sourceNames.toSet()).size shouldBe sourceNames.size 54 | } 55 | 56 | values[1].apply { 57 | key shouldBe "b" 58 | description shouldBe "feature b desc" 59 | defaultValue shouldBe true 60 | sourceSelectionStrategy.select(sourceNames.toSet()).size shouldBe 0 61 | } 62 | 63 | values[2].apply { 64 | key shouldBe "c" 65 | description shouldBe "feature c desc" 66 | defaultValue shouldBe "feature c" 67 | sourceSelectionStrategy.select(sourceNames.toSet()).first() shouldBe selectedSource 68 | } 69 | } 70 | } 71 | 72 | @Test 73 | fun `when source have value - config should return it`() { 74 | // GIVEN 75 | val source = createTestSource( 76 | name = "Test source", 77 | values = mapOf("a" to false), 78 | ) 79 | val featureConfig = TestFeatureConfig() 80 | 81 | konfeature { 82 | addSource(source) 83 | register(featureConfig) 84 | } 85 | 86 | // WHEN 87 | val a = featureConfig.a 88 | 89 | // THEN 90 | a shouldBe false 91 | } 92 | 93 | @Test 94 | fun `when source don't have value - config should return default value`() { 95 | // GIVEN 96 | val source = createTestSource( 97 | name = "Test source", 98 | values = mapOf("b" to false), 99 | ) 100 | val featureConfig = TestFeatureConfig() 101 | 102 | konfeature { 103 | addSource(source) 104 | register(featureConfig) 105 | } 106 | 107 | // WHEN 108 | val a = featureConfig.a 109 | 110 | // THEN 111 | a shouldBe true 112 | } 113 | 114 | @Test 115 | fun `when source have value with unexpected type - config should return default value`() { 116 | // GIVEN 117 | val source = createTestSource( 118 | name = "Test source", 119 | values = mapOf("a" to 5), 120 | ) 121 | val featureConfig = TestFeatureConfig() 122 | 123 | konfeature { 124 | addSource(source) 125 | register(featureConfig) 126 | } 127 | 128 | // WHEN 129 | val a = featureConfig.a 130 | 131 | // THEN 132 | a shouldBe true 133 | } 134 | 135 | @Test 136 | fun `when both sources contain same key - config should return value of first added source`() { 137 | // GIVEN 138 | val source1 = createTestSource( 139 | name = "Test source 1", 140 | values = mapOf("a" to false), 141 | ) 142 | val source2 = createTestSource( 143 | name = "Test source 2", 144 | values = mapOf("a" to true), 145 | ) 146 | 147 | val featureConfig = TestFeatureConfig() 148 | 149 | // WHEN 150 | konfeature { 151 | addSource(source1) 152 | addSource(source2) 153 | register(featureConfig) 154 | } 155 | 156 | val a = featureConfig.a 157 | 158 | // THEN 159 | a shouldBe false 160 | } 161 | 162 | @Test 163 | fun `when source specified by SourceSelectionStrategy - config should return value from it`() { 164 | // GIVEN 165 | val source1 = createTestSource( 166 | name = "Test source 1", 167 | values = mapOf("c" to "test_source_1_c"), 168 | ) 169 | val source2 = createTestSource( 170 | name = "Test source 2", 171 | values = mapOf("c" to "test_source_2_c"), 172 | ) 173 | 174 | val featureConfig = TestFeatureConfig( 175 | cSourceSelectionStrategy = SourceSelectionStrategy.anyOf(source2.name), 176 | ) 177 | 178 | // WHEN 179 | konfeature { 180 | addSource(source1) 181 | addSource(source2) 182 | register(featureConfig) 183 | } 184 | 185 | val c = featureConfig.c 186 | 187 | // THEN 188 | c shouldBe "test_source_2_c" 189 | } 190 | 191 | @Test 192 | fun `when value changed by interceptor - config should return it`() { 193 | // GIVEN 194 | val source = createTestSource( 195 | name = "Test source", 196 | values = mapOf( 197 | "a" to false, 198 | "b" to true, 199 | "c" to "test_source_1_c", 200 | ), 201 | ) 202 | 203 | val interceptedValue = "intercepted_value_c" 204 | 205 | val interceptor = object : Interceptor { 206 | override val name: String = "test interceptor" 207 | 208 | override fun intercept(valueSource: FeatureValueSource, key: String, value: Any): Any? { 209 | return if (key == "c") interceptedValue else null 210 | } 211 | } 212 | 213 | val featureConfig = TestFeatureConfig() 214 | 215 | // WHEN 216 | konfeature { 217 | addSource(source) 218 | addInterceptor(interceptor) 219 | register(featureConfig) 220 | } 221 | 222 | // THEN 223 | assertSoftly(featureConfig) { 224 | a shouldBe false 225 | b shouldBe true 226 | c shouldBe interceptedValue 227 | } 228 | } 229 | 230 | @Test 231 | fun `when value changed by interceptor but has unexpected type - config should return default`() { 232 | // GIVEN 233 | val interceptor = object : Interceptor { 234 | override val name: String = "test interceptor" 235 | 236 | override fun intercept(valueSource: FeatureValueSource, key: String, value: Any): Any? { 237 | return if (key == "c") 100 else null 238 | } 239 | } 240 | 241 | val featureConfig = TestFeatureConfig() 242 | 243 | // WHEN 244 | konfeature { 245 | addInterceptor(interceptor) 246 | register(featureConfig) 247 | } 248 | 249 | // THEN 250 | featureConfig.c shouldBe "feature c" 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /konfeature/src/commonTest/kotlin/com/redmadrobot/konfeature/builder/KonfeatureBuilderTest.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature.builder 2 | 3 | import com.redmadrobot.konfeature.exception.ConfigNameAlreadyExistException 4 | import com.redmadrobot.konfeature.exception.KeyDuplicationException 5 | import com.redmadrobot.konfeature.exception.NoFeatureConfigException 6 | import com.redmadrobot.konfeature.exception.SourceNameAlreadyExistException 7 | import com.redmadrobot.konfeature.helper.TestFeatureConfig 8 | import com.redmadrobot.konfeature.helper.createEmptyFeatureConfig 9 | import com.redmadrobot.konfeature.helper.createTestSource 10 | import io.kotest.assertions.throwables.shouldThrow 11 | import io.kotest.matchers.shouldBe 12 | import kotlin.test.Test 13 | 14 | class KonfeatureBuilderTest { 15 | 16 | @Test 17 | fun `when no any feature config registered - should throw exception`() { 18 | shouldThrow { 19 | KonfeatureBuilder().build() 20 | } 21 | } 22 | 23 | @Test 24 | fun `when config with duplicated keys added - should throw exception`() { 25 | val featureConfig = TestFeatureConfig(withDuplicates = true) 26 | 27 | val exception = shouldThrow { 28 | KonfeatureBuilder().register(featureConfig).build() 29 | } 30 | 31 | exception.message shouldBe "values with keys <'a'> are duplicated in config '${featureConfig.name}'" 32 | } 33 | 34 | @Test 35 | fun `when source with same name added twice - should throw exception`() { 36 | val featureConfigName = "Test Feature Config" 37 | 38 | val sourceName = "Test Source" 39 | 40 | val exception = shouldThrow { 41 | konfeature { 42 | addSource(createTestSource(sourceName)) 43 | addSource(createTestSource(sourceName)) 44 | register(createEmptyFeatureConfig(featureConfigName)) 45 | } 46 | } 47 | 48 | exception.message shouldBe "source with name '$sourceName' already registered" 49 | } 50 | 51 | @Test 52 | fun `when feature config with same name registered twice - should throw exception`() { 53 | val featureConfigName = "Test Feature Config" 54 | 55 | val exception = shouldThrow { 56 | konfeature { 57 | register(createEmptyFeatureConfig(featureConfigName)) 58 | register(createEmptyFeatureConfig(featureConfigName)) 59 | } 60 | } 61 | 62 | exception.message shouldBe "feature config with name '$featureConfigName' already registered" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /konfeature/src/commonTest/kotlin/com/redmadrobot/konfeature/helper/KonfeatureTestHelper.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature.helper 2 | 3 | import com.redmadrobot.konfeature.FeatureConfig 4 | import com.redmadrobot.konfeature.source.FeatureSource 5 | 6 | fun createTestSource( 7 | name: String, 8 | values: Map = emptyMap(), 9 | ): FeatureSource { 10 | return object : FeatureSource { 11 | 12 | override val name: String = name 13 | 14 | override fun get(key: String): Any? = values[key] 15 | } 16 | } 17 | 18 | fun createEmptyFeatureConfig( 19 | name: String, 20 | description: String = "test description for $name", 21 | ): FeatureConfig { 22 | return object : FeatureConfig(name = name, description = description) {} 23 | } 24 | -------------------------------------------------------------------------------- /konfeature/src/commonTest/kotlin/com/redmadrobot/konfeature/helper/TestFeatureConfig.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature.helper 2 | 3 | import com.redmadrobot.konfeature.FeatureConfig 4 | import com.redmadrobot.konfeature.source.SourceSelectionStrategy 5 | 6 | class TestFeatureConfig( 7 | withDuplicates: Boolean = false, 8 | cSourceSelectionStrategy: SourceSelectionStrategy = SourceSelectionStrategy.Any, 9 | ) : FeatureConfig( 10 | name = "TestFeatureConfig", 11 | description = "TestFeatureConfig description", 12 | ) { 13 | val a by toggle( 14 | key = "a", 15 | description = "feature a desc", 16 | defaultValue = true, 17 | sourceSelectionStrategy = SourceSelectionStrategy.Any, 18 | ) 19 | 20 | val b by toggle( 21 | key = if (withDuplicates) "a" else "b", 22 | description = "feature b desc", 23 | defaultValue = true, 24 | sourceSelectionStrategy = SourceSelectionStrategy.None, 25 | ) 26 | 27 | val c: String by value( 28 | key = if (withDuplicates) "a" else "c", 29 | description = "feature c desc", 30 | defaultValue = "feature c", 31 | sourceSelectionStrategy = cSourceSelectionStrategy, 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Prepares the library for release. Creates a release branch from the 'main'. 4 | # 5 | # Usage: ./release.sh [version] 6 | # Example: ./release.sh 1.0.0 7 | # 8 | # Original release script: https://github.com/RedMadRobot/android-library-template/blob/main/release.sh 9 | 10 | set -euo pipefail 11 | 12 | # The script could be run from any directory. 13 | cd "$(dirname "$0")" 14 | 15 | # Configure the script 16 | properties="gradle.properties" 17 | changelog="CHANGELOG.md" 18 | readme="README.md" 19 | files_to_update_version=("$properties" "$readme") 20 | github_repository_url="https://github.com/RedMadRobot/Konfeature" 21 | 22 | #region Utils 23 | function error() { 24 | echo "❌ $1" 25 | return 1 26 | } 27 | 28 | function property { 29 | grep "^${1}=" "$properties" | cut -d'=' -f2 30 | } 31 | 32 | function replace() { 33 | # Escape linebreaks 34 | local replacement=${2//$'\n'/\\\n} 35 | # Portable in-place edit. 36 | # See: https://stackoverflow.com/a/4247319 37 | sed -i".bak" -E "s~$1~$replacement~g" "$3" 38 | rm "$3.bak" 39 | } 40 | 41 | function diff_link() { 42 | echo -n "$github_repository_url/compare/${1}...${2}" 43 | } 44 | #endregion 45 | 46 | # Validate input parameters 47 | version=${1:-Please, specify the version to be released as a script parameter} 48 | [[ $version != v* ]] || error "The version should not start from 'v'" 49 | version_tag="v$version" 50 | 51 | # 0. Fetch remote changes 52 | echo "️⏳ Creating release branch..." 53 | release_branch="release/$version" 54 | git checkout --quiet -b "$release_branch" 55 | git pull --quiet --rebase origin main 56 | echo "✅ Branch '$release_branch' created" 57 | echo 58 | 59 | # 1. Calculate version values for later 60 | last_version=$(property "version") 61 | if [[ "$last_version" == "$version" ]]; then 62 | echo "🤔 Version $version is already set." 63 | exit 0 64 | fi 65 | echo "🚀 Update $last_version → $version" 66 | echo 67 | 68 | # 2. Update version everywhere 69 | for file in "${files_to_update_version[@]}" ; do 70 | replace "$last_version" "$version" "$file" 71 | echo "✅ Updated version in $file" 72 | done 73 | 74 | # 3. Update header in CHANGELOG.md 75 | date=$(date -u +%Y-%m-%d) 76 | header_replacement=\ 77 | "## [Unreleased] 78 | 79 | ### Changes 80 | 81 | - *No changes* 82 | 83 | ## [$version] ($date)" 84 | replace "^## \[Unreleased\].*" "$header_replacement" "$changelog" 85 | echo "✅ Updated CHANGELOG.md header" 86 | 87 | # 4. Add link to version diff 88 | unreleased_diff_link="[unreleased]: $(diff_link "$version_tag" "main")" 89 | version_diff_link="[$version]: $(diff_link "v$last_version" "$version_tag")" 90 | replace "^\[unreleased\]:.*" "$unreleased_diff_link\n$version_diff_link" "$changelog" 91 | echo "✅ Added a diff link to CHANGELOG.md" 92 | 93 | # 5. Ask if the changes should be pushed to remote branch 94 | echo 95 | echo "Do you want to commit the changes and push the release branch and tag?" 96 | echo "The release tag push triggers a release workflow on CI." 97 | read -p " Enter 'yes' to continue: " -r input 98 | if [[ "$input" != "yes" ]]; then 99 | echo "👌 SKIPPED." 100 | exit 0 101 | fi 102 | 103 | # 6. Push changes and trigger release on CI 104 | echo 105 | echo "⏳ Pushing the changes to the remote repository..." 106 | git add "$readme" "$changelog" "$properties" 107 | git commit --quiet --message "version: $version" 108 | git tag "$version_tag" 109 | git push --quiet origin HEAD "$version_tag" 110 | echo "🎉 DONE." 111 | echo "Create a Pull Request: $(diff_link "main" "$release_branch")" 112 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":semanticCommitScopeDisabled" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /sample/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | application 4 | convention.detekt 5 | } 6 | dependencies { 7 | implementation(projects.konfeature) 8 | } 9 | 10 | application { 11 | mainClass = "com.redmadrobot.konfeature.sample.AppKt" 12 | } 13 | -------------------------------------------------------------------------------- /sample/src/main/kotlin/com/redmadrobot/konfeature/sample/App.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature.sample 2 | 3 | import com.redmadrobot.konfeature.Logger 4 | import com.redmadrobot.konfeature.builder.konfeature 5 | 6 | fun main() { 7 | val featureConfig = SampleFeatureConfig() 8 | 9 | val debugPanelInterceptor = FeatureToggleDebugPanelInterceptor() 10 | 11 | val logger = object : Logger { 12 | override fun log(severity: Logger.Severity, message: String) { 13 | println("${severity.name}: $message") 14 | } 15 | } 16 | 17 | val konfeature = konfeature { 18 | addSource(RemoteFeatureSource()) 19 | addSource(FirebaseFeatureSource()) 20 | register(featureConfig) 21 | addInterceptor(debugPanelInterceptor) 22 | setLogger(logger) 23 | } 24 | 25 | konfeature.spec.forEach { 26 | println("Spec: --name: '${it.name}', description: '${it.description}'") 27 | it.values.forEach(::println) 28 | } 29 | 30 | println() 31 | val spec = konfeature.spec.first().values.first() 32 | println("getFeatureValue('${spec.key}') -> ${konfeature.getValue(spec)}") 33 | 34 | println() 35 | println("feature1: " + featureConfig.isFeature1Enabled) 36 | println("feature2: " + featureConfig.isFeature2Enabled) 37 | println("feature3: " + featureConfig.isFeature3Enabled) 38 | println("velocity: " + featureConfig.velocity) 39 | println("puhFeature: " + featureConfig.puhFeature) 40 | 41 | debugPanelInterceptor.setFeatureValue("feature2", false) 42 | println() 43 | println("debugPanelInterceptor.setFeatureValue(\"feature2\", false)") 44 | println("feature1: " + featureConfig.isFeature1Enabled) 45 | println("feature2: " + featureConfig.isFeature2Enabled) 46 | println("feature3: " + featureConfig.isFeature3Enabled) 47 | } 48 | -------------------------------------------------------------------------------- /sample/src/main/kotlin/com/redmadrobot/konfeature/sample/FeatureToggleDebugPanelInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature.sample 2 | 3 | import com.redmadrobot.konfeature.source.FeatureValueSource 4 | import com.redmadrobot.konfeature.source.Interceptor 5 | 6 | class FeatureToggleDebugPanelInterceptor : Interceptor { 7 | 8 | private val values = mutableMapOf() 9 | 10 | override val name: String = "DebugPanelInterceptor" 11 | 12 | override fun intercept(valueSource: FeatureValueSource, key: String, value: Any): Any? { 13 | return values[key] 14 | } 15 | 16 | fun setFeatureValue(key: String, value: Any) { 17 | values[key] = value 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /sample/src/main/kotlin/com/redmadrobot/konfeature/sample/SampleFeatureConfig.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature.sample 2 | 3 | import com.redmadrobot.konfeature.FeatureConfig 4 | import com.redmadrobot.konfeature.source.SourceSelectionStrategy 5 | 6 | class SampleFeatureConfig : FeatureConfig( 7 | name = "Sample", 8 | description = "simple sample set" 9 | ) { 10 | 11 | val isFeature1Enabled: Boolean by toggle( 12 | key = "feature1", 13 | description = "feature1 desc", 14 | defaultValue = false, 15 | ) 16 | 17 | val isFeature2Enabled: Boolean by toggle( 18 | key = "feature2", 19 | description = "feature2 desc", 20 | defaultValue = true, 21 | sourceSelectionStrategy = SourceSelectionStrategy.Any 22 | ) 23 | 24 | val isFeature3Enabled: Boolean by toggle( 25 | key = "feature3", 26 | description = "feature3 desc", 27 | defaultValue = false, 28 | sourceSelectionStrategy = SourceSelectionStrategy.Any 29 | ) 30 | 31 | val velocity: Long by value( 32 | key = "velocity_value", 33 | description = "velocity value", 34 | defaultValue = 90, 35 | sourceSelectionStrategy = SourceSelectionStrategy.Any 36 | ) 37 | 38 | val isGroupFeatureEnable: Boolean 39 | get() = isFeature1Enabled && isFeature3Enabled 40 | 41 | val feature4: String by value( 42 | key = "feature4", 43 | description = "feature4 desc", 44 | defaultValue = "true", 45 | sourceSelectionStrategy = SourceSelectionStrategy.Any 46 | ) 47 | 48 | enum class PUH { A, B, C } 49 | 50 | private val _puhFeature by value( 51 | key = "puhFeature", 52 | description = "puhFeature desc", 53 | defaultValue = PUH.B.name, 54 | sourceSelectionStrategy = SourceSelectionStrategy.Any 55 | ) 56 | 57 | val puhFeature: PUH 58 | get() = _puhFeature.let { PUH.valueOf(it) } 59 | } 60 | -------------------------------------------------------------------------------- /sample/src/main/kotlin/com/redmadrobot/konfeature/sample/SampleSources.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.konfeature.sample 2 | 3 | import com.redmadrobot.konfeature.source.FeatureSource 4 | 5 | class RemoteFeatureSource : FeatureSource { 6 | 7 | private val store = mutableMapOf().apply { 8 | put("feature1", false) 9 | put("feature3", true) 10 | put("velocity_value", true) 11 | } 12 | 13 | override val name: String = "RemoteFeatureToggleSource" 14 | 15 | override fun get(key: String): Any? { 16 | return store[key] 17 | } 18 | } 19 | 20 | class FirebaseFeatureSource : FeatureSource { 21 | 22 | private val store = mutableMapOf().apply { 23 | put("feature2", true) 24 | put("puhFeature", "C") 25 | } 26 | 27 | override val name: String = "FirebaseFeatureToggleSource" 28 | 29 | override fun get(key: String): Any? { 30 | return store[key] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 2 | 3 | pluginManagement { 4 | repositories { 5 | google { 6 | content { 7 | includeGroupAndSubgroups("com.android") 8 | includeGroupAndSubgroups("com.google") 9 | includeGroupAndSubgroups("androidx") 10 | } 11 | } 12 | gradlePluginPortal() 13 | } 14 | } 15 | 16 | @Suppress("UnstableApiUsage") 17 | dependencyResolutionManagement { 18 | repositories { 19 | mavenCentral() 20 | } 21 | } 22 | 23 | rootProject.name = "konfeature-root" 24 | 25 | include( 26 | ":sample", 27 | ":konfeature", 28 | ) 29 | --------------------------------------------------------------------------------