From da1b0477e0af1bdb672232626605818c29d574bc Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Jun 2026 16:36:07 +0400 Subject: [PATCH] Updated on 2026-08-14 --- CLAUDE.md | 2 +- core/config-toggles/CLAUDE.md | 70 +++++++++++++++++++ .../configs/feature_toggles_config.json | 4 ++ .../features/home/api/HomeFeatureToggles.kt | 6 ++ features/home/impl/build.gradle.kts | 1 + .../home/impl/DefaultHomeFeatureToggles.kt | 13 ++++ .../home/impl/di/HomeFeatureModule.kt | 15 ++++ .../features/home/impl/model/HomeModel.kt | 3 + .../features/home/impl/ui/state/HomeUM.kt | 1 + 9 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 core/config-toggles/CLAUDE.md create mode 100644 features/home/api/src/main/kotlin/com/tangem/features/home/api/HomeFeatureToggles.kt create mode 100644 features/home/impl/src/main/kotlin/com/tangem/features/home/impl/DefaultHomeFeatureToggles.kt diff --git a/CLAUDE.md b/CLAUDE.md index d093519ee6..0dc488bd3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,7 +102,7 @@ The app uses [Decompose](https://github.com/arkivanov/Decompose) for lifecycle-a - **Async:** Kotlin Coroutines + Flow. Inject `CoroutineDispatcherProvider` (from `core/utils`) instead of using `Dispatchers.*` directly — provides `main`, `mainImmediate`, `io`, `default`, `single` - **Error handling:** Arrow's `Either` pattern throughout domain/data layers. `DataError` sealed hierarchy for domain errors. See `domain/core/CLAUDE.md` for the LCE pattern - **Analytics:** `AnalyticsEvent(category, event, params)` in `core/analytics/models/`. Feature events are sealed class hierarchies extending `AnalyticsEvent`. Send via injected `AnalyticsEventHandler` -- **Feature toggles:** `FeatureTogglesManager` in `core/config-toggles/`. Toggles are defined in `core/config-toggles/src/main/assets/configs/feature_toggles_config.json` and auto-generated into a `FeatureToggles` enum by the convention plugin at build time. Each feature module exposes its own `XxxFeatureToggles` interface (in `api/`) with a `DefaultXxxFeatureToggles` implementation (in `impl/`) that delegates to `FeatureTogglesManager` +- **Feature toggles:** `FeatureTogglesManager` in `core/config-toggles/`. See `core/config-toggles/CLAUDE.md`. - **Supported languages:** `SupportedLanguages` in `core/utils/` defines the app's supported locales: en, ru, de, fr, it, ja, uk, zh, es. `getCurrentSupportedLanguageCode()` returns the device locale if supported, otherwise falls back to English. Used by API calls that accept a language parameter ### Build System diff --git a/core/config-toggles/CLAUDE.md b/core/config-toggles/CLAUDE.md new file mode 100644 index 0000000000..0d2107635e --- /dev/null +++ b/core/config-toggles/CLAUDE.md @@ -0,0 +1,70 @@ +# core/config-toggles + +Feature toggles (and the related excluded-blockchains toggles). Toggles gate +features by app version; the JSON config is the source of truth and the +`FeatureToggles` enum is generated from it at build time. + +## How it works + +- **Config:** `src/main/assets/configs/feature_toggles_config.json` — a JSON array + of `{ "name": , "version": }` (`ConfigToggle`). +- The **convention plugin** generates the `FeatureToggles` enum (one entry per + `name`) at build time. Reference it as `FeatureToggles.`. +- **Entry point:** `FeatureTogglesManager.isFeatureEnabled(FeatureToggles.X)`. + - `ProdFeatureTogglesManager` (release): a toggle is enabled when the app + version `>=` its `version`. + - `DevFeatureTogglesManager` (tester builds, `BuildConfig.TESTER_MENU_ENABLED`): + runtime-toggleable via the Tester Menu. +- **`version` semantics:** + - `"undefined"` (`DISABLED_FEATURE_TOGGLE_VERSION`) → OFF in prod; can only be + flipped ON via the Tester Menu / dev builds. Use this while a feature is in + development. + - `"X.Y"` (e.g. `5.40`) → ON in prod from that app version onward + (`currentVersion >= localVersion`, see `VersionAvailabilityContract`). + +## Naming convention (ENFORCED by a test) + +- A toggle `name` MUST match `^(AND|TWI)_\d+(?:_[A-Z0-9]+)+$` — start with the + Jira ticket id (`AND_` for Android tickets, `TWI_` for idea tickets), + then an `UPPER_SNAKE_CASE` suffix. Example: `AND_15901_STORIES_CONTAINER_ENABLED`. +- Enforced by `FeatureTogglesNamingConventionTest`. Legacy toggles that predate + the rule are whitelisted in its `EXCLUDED_TOGGLES_LIST` — do **not** add new + names there without an explicit reason. +- The Kotlin interface property stays human-readable **without** the ticket id: + `isStoriesContainerEnabled`. + +## Per-feature toggles & how to add one + +Each feature owns its toggles — feature code reads them through its own +interface, never `FeatureTogglesManager` directly: + +- `api/`: `XxxFeatureToggles` interface — `val isYyyEnabled: Boolean`. +- `impl/`: `DefaultXxxFeatureToggles(featureTogglesManager)` exposes each toggle as + a **getter-backed property**, not a stored value — so it is re-evaluated on every + read (required for runtime toggling via the Tester Menu): + + ```kotlin + override val isYyyEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND__YYY) + ``` + + Never `val isYyyEnabled = featureTogglesManager.isFeatureEnabled(...)` (evaluated + once at construction). +- DI: a `@Provides @Singleton` in the feature's Hilt module returning the interface. + +To add a toggle: + +1. Add `{ "name": "AND__FOO_ENABLED", "version": "undefined" }` to the config + JSON (the enum is regenerated at build). +2. Add `val isFooEnabled` to the feature's `XxxFeatureToggles` and map it in + `DefaultXxxFeatureToggles` (create the interface/impl/DI provider if the + feature has none yet). +3. Gate code on `xxxFeatureToggles.isFooEnabled`. + +## Removing (cleanup) + +When a toggle ships at 100%, set its `version` to the release and run the +`cleanup-feature-toggles` skill — it removes the JSON entry, the interface/impl +members, inlines `true`, and drops dead branches. Mark code that must be deleted +together with a toggle using `@RemoveWithToggle("AND__FOO_ENABLED")` +(`com.tangem.utils.annotations.RemoveWithToggle`); the cleanup skill picks it up. \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index e0464082f0..a9cc20669f 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -1,4 +1,8 @@ [ + { + "name": "AND_15901_STORIES_CONTAINER_ENABLED", + "version": "undefined" + }, { "name": "NEW_CARD_SCANNING_ENABLED", "version": "undefined" diff --git a/features/home/api/src/main/kotlin/com/tangem/features/home/api/HomeFeatureToggles.kt b/features/home/api/src/main/kotlin/com/tangem/features/home/api/HomeFeatureToggles.kt new file mode 100644 index 0000000000..c991bd16d5 --- /dev/null +++ b/features/home/api/src/main/kotlin/com/tangem/features/home/api/HomeFeatureToggles.kt @@ -0,0 +1,6 @@ +package com.tangem.features.home.api + +interface HomeFeatureToggles { + + val isStoriesContainerEnabled: Boolean +} \ No newline at end of file diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index afa7941725..60bb2f652f 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(projects.core.analytics.models) implementation(projects.core.navigation) implementation(projects.core.utils) + implementation(projects.core.configToggles) /** Common */ implementation(projects.common.routing) diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/DefaultHomeFeatureToggles.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/DefaultHomeFeatureToggles.kt new file mode 100644 index 0000000000..81085edfe0 --- /dev/null +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/DefaultHomeFeatureToggles.kt @@ -0,0 +1,13 @@ +package com.tangem.features.home.impl + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.home.api.HomeFeatureToggles + +internal class DefaultHomeFeatureToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : HomeFeatureToggles { + + override val isStoriesContainerEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15901_STORIES_CONTAINER_ENABLED) +} \ No newline at end of file diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/di/HomeFeatureModule.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/di/HomeFeatureModule.kt index cb6516bc9b..5b764ba1c6 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/di/HomeFeatureModule.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/di/HomeFeatureModule.kt @@ -1,12 +1,16 @@ package com.tangem.features.home.impl.di +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.features.home.api.HomeComponent +import com.tangem.features.home.api.HomeFeatureToggles import com.tangem.features.home.impl.DefaultHomeComponent +import com.tangem.features.home.impl.DefaultHomeFeatureToggles import com.tangem.features.home.impl.model.HomeModel import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.ClassKey @@ -22,6 +26,17 @@ internal interface ComponentModule { fun bindComponent(factory: DefaultHomeComponent.Factory): HomeComponent.Factory } +@Module +@InstallIn(SingletonComponent::class) +internal object HomeFeatureTogglesModule { + + @Provides + @Singleton + fun provideHomeFeatureToggles(featureTogglesManager: FeatureTogglesManager): HomeFeatureToggles { + return DefaultHomeFeatureToggles(featureTogglesManager) + } +} + @Module @InstallIn(ModelComponent::class) internal interface ModelModule { diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt index 147e146fa6..86f2175a22 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt @@ -34,6 +34,7 @@ import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.feature.referral.domain.ShouldShowMobileWalletPromoUseCase import com.tangem.features.home.api.HomeComponent +import com.tangem.features.home.api.HomeFeatureToggles import com.tangem.features.home.impl.ui.state.HomeUM import com.tangem.features.home.impl.ui.state.Stories import com.tangem.features.home.impl.ui.state.getRestrictedStories @@ -67,6 +68,7 @@ internal class HomeModel @Inject constructor( private val urlOpener: UrlOpener, private val userWalletsListRepository: UserWalletsListRepository, private val shouldShowMobileWalletPromoUseCase: ShouldShowMobileWalletPromoUseCase, + private val homeFeatureToggles: HomeFeatureToggles, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { @@ -77,6 +79,7 @@ internal class HomeModel @Inject constructor( private val _uiState = MutableStateFlow( HomeUM( scanInProgress = false, + isStoriesContainerEnabled = homeFeatureToggles.isStoriesContainerEnabled, stories = getRestrictedStories().toImmutableList(), onShopClick = ::onShopClick, onSearchTokensClick = ::onSearchTokensClick, diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt index 924ca985fb..bdc99712d2 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt @@ -4,6 +4,7 @@ import kotlinx.collections.immutable.ImmutableList data class HomeUM( val scanInProgress: Boolean, + val isStoriesContainerEnabled: Boolean, val stories: ImmutableList, val onShopClick: () -> Unit, val onSearchTokensClick: () -> Unit,