Repository: wordpress-mobile/WordPress-Android (language: kotlin)

Issue:
🤖 [Dynamic Cards] Implement card display logic according to the remote feature flags
When parsing the remote cards data, we will check the value of the remote-feature-flag against the remote feature flags endpoint response. If the key exists in the remote feature flags endpoint response, we will respect the value returned. If the key doesn’t exist, it means there are no restrictions, and we’ll show the card to all users. To control a certain card, we’ll add a corresponding remote feature flag similar to how we treat normal feature flags. The flag’s key will match the value of remote-feature-flag outlined in the cards data.

ref pc8HXX-1nA-p2


Discussion hints:
nan

Pull request: 🤖 [Dynamic Cards] Implement card display logic according to the remote feature flags
Fixes #19797

## Description
This PR checks the remote feature flag key of each card returned from the endpoint and:
* If the key exists in the remote feature flags endpoint response, the remote flag value controls the card visibility
* If the key doesn’t exist, it means there are no restrictions, and the card is shown

-----

## To Test:
- Apply the [remotecards.patch](https://github.com/wordpress-mobile/WordPress-Android/files/13703174/remotecards.patch) and run the app.
- **Verify** that the cards with the following titles are visible:
  - `Enabled remote feature flag`
  - `Non existent remote feature flag`
  - `Empty feature flag`
 - **Verify** that the card with title `Disabled by remote feature flag` is not visible


https://github.com/wordpress-mobile/WordPress-Android/assets/304044/a297d83f-c43b-416f-b78b-6f8298792a5a

-----

## Regression Notes

1. Potential unintended areas of impact

    My Site Dashboard screen and a dynamic dashboard card

2. What I did to test those areas of impact (or what existing automated tests I relied on)

    Manual testing

3. What automated tests I added (or what prevented me from doing so)

    Added new test cases in the `DynamicCardsBuilderTest`

-----

## PR Submission Checklist:

- [x] I have completed the Regression Notes.
- [x] I have considered adding accessibility improvements for my changes.
- [x] I have considered if this change warrants user-facing release notes and have added them to `RELEASE-NOTES.txt` if necessary.

-----

## UI Changes Testing Checklist:

- [ ] Portrait and landscape orientations.
- [ ] Light and dark modes.
- [ ] Fonts: Larger, smaller and bold text.
- [ ] High contrast.
- [ ] Talkback.
- [ ] Languages with large words or with letters/accents not frequently used in English.
- [ ] Right-to-left languages. (Even if translation isn’t complete, formatting should still respect the right-to-left layout)
- [ ] Large and small screen sizes. (Tablet and smaller phones)
- [ ] Multi-tasking: Split screen and Pop-up view. (Android 10 or higher)


Test command: ./gradlew :WordPress:testWordpressVanillaDebugUnitTest --tests "org.wordpress.android.ui.mysite.cards.dynamiccard.DynamicCardsBuilderTest"

Fix patch:
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSource.kt
index 9339b1d9abe8..33b3aa292daa 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSource.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSource.kt
@@ -118,7 +118,7 @@ class CardsSource @Inject constructor(
     }
 
     private fun shouldRequestDynamicCards(): Boolean {
-        return dynamicDashboardCardsFeatureConfig.isEnabled() // TODO We should also add check for hidden cards here
+        return dynamicDashboardCardsFeatureConfig.isEnabled()
     }
 
     private fun MediatorLiveData<CardsUpdate>.postErrorState() {
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilder.kt
index 14bf2e8c55a9..403248cd48aa 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilder.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilder.kt
@@ -1,6 +1,7 @@
 package org.wordpress.android.ui.mysite.cards.dynamiccard
 
 import org.wordpress.android.fluxc.model.dashboard.CardModel
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.DynamicCardModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.CardOrder
 import org.wordpress.android.ui.deeplinks.handlers.DeepLinkHandlers
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.Dynamic
@@ -8,26 +9,29 @@ import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams.DynamicCar
 import org.wordpress.android.ui.utils.ListItemInteraction.Companion.create
 import org.wordpress.android.util.UriWrapper
 import org.wordpress.android.util.UrlUtilsWrapper
+import org.wordpress.android.util.config.DynamicDashboardCardsFeatureConfig
+import org.wordpress.android.util.config.FeatureFlagConfig
 import javax.inject.Inject
 
 class DynamicCardsBuilder @Inject constructor(
     private val urlUtils: UrlUtilsWrapper,
-    private val deepLinkHandlers: DeepLinkHandlers
+    private val deepLinkHandlers: DeepLinkHandlers,
+    private val dynamicDashboardCardsFeatureConfig: DynamicDashboardCardsFeatureConfig,
+    private val featureFlagConfig: FeatureFlagConfig,
 ) {
     fun build(params: DynamicCardsBuilderParams, order: CardOrder): List<Dynamic>? {
-        if (!shouldBuildCard(params, order)) {
+        if (!dynamicDashboardCardsFeatureConfig.isEnabled() || !shouldBuildCard(params, order)) {
             return null
         }
         return convertToDynamicCards(params, order)
     }
 
     private fun shouldBuildCard(params: DynamicCardsBuilderParams, order: CardOrder): Boolean {
-        if (params.dynamicCards == null || params.dynamicCards.dynamicCards.none { it.order == order }) return false
-        return true
+        return !(params.dynamicCards == null || params.dynamicCards.dynamicCards.none { it.order == order })
     }
 
     private fun convertToDynamicCards(params: DynamicCardsBuilderParams, order: CardOrder): List<Dynamic> {
-        val cards = params.dynamicCards?.dynamicCards?.filter { it.order == order } ?: emptyList()
+        val cards = params.dynamicCards?.dynamicCards?.filter { it.order == order && it.isEnabled()}.orEmpty()
         return cards.map { card ->
             Dynamic(
                 id = card.id,
@@ -46,6 +50,14 @@ class DynamicCardsBuilder @Inject constructor(
         }
     }
 
+    fun DynamicCardModel.isEnabled(): Boolean {
+        // If there is no feature flag or there is no such remote feature flag, then the card is enabled
+        if (remoteFeatureFlag.isNullOrEmpty() ||
+            featureFlagConfig.getString(requireNotNull(remoteFeatureFlag)).isEmpty()
+        ) return true
+        return featureFlagConfig.isEnabled(requireNotNull(remoteFeatureFlag))
+    }
+
     private fun getActionSource(
         params: DynamicCardsBuilderParams,
         card: CardModel.DynamicCardsModel.DynamicCardModel


Test patch:
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilderTest.kt
index 301a867dabb3..f6e664ba74b3 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilderTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilderTest.kt
@@ -13,12 +13,13 @@ import org.wordpress.android.ui.deeplinks.handlers.DeepLinkHandlers
 import org.wordpress.android.ui.mysite.MySiteCardAndItem
 import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams
 import org.wordpress.android.util.UrlUtilsWrapper
+import org.wordpress.android.util.config.DynamicDashboardCardsFeatureConfig
+import org.wordpress.android.util.config.FeatureFlagConfig
 import kotlin.test.assertEquals
 
 /* DYNAMIC CARDS */
 private const val DYNAMIC_CARD_ID = "year_in_review_2023"
 private const val DYNAMIC_CARD_TITLE = "News"
-private const val DYNAMIC_CARD_REMOTE_FEATURE_FLAG = "dynamic_dashboard_cards"
 private const val DYNAMIC_CARD_FEATURED_IMAGE = "https://path/to/image"
 private const val DYNAMIC_CARD_URL = "https://wordpress.com"
 private const val DYNAMIC_CARD_ACTION = "Call to action"
@@ -27,6 +28,10 @@ private const val DYNAMIC_CARD_ROW_ICON = "https://path/to/image"
 private const val DYNAMIC_CARD_ROW_TITLE = "Row title"
 private const val DYNAMIC_CARD_ROW_DESCRIPTION = "Row description"
 
+private const val ENABLED_REMOTE_FEATURE_FLAG = "enabled_remote_feature_flag"
+private const val DISABLED_REMOTE_FEATURE_FLAG = "disabled_remote_feature_flag"
+private const val UNKNOWN_REMOTE_FEATURE_FLAG = "unknown_remote_feature_flag"
+
 private val DYNAMIC_CARD_ROW_MODEL = CardModel.DynamicCardsModel.DynamicCardRowModel(
     icon = DYNAMIC_CARD_ROW_ICON,
     title = DYNAMIC_CARD_ROW_TITLE,
@@ -36,7 +41,7 @@ private val DYNAMIC_CARD_ROW_MODEL = CardModel.DynamicCardsModel.DynamicCardRowM
 private val DYNAMIC_CARD_MODEL = CardModel.DynamicCardsModel.DynamicCardModel(
     id = DYNAMIC_CARD_ID,
     title = DYNAMIC_CARD_TITLE,
-    remoteFeatureFlag = DYNAMIC_CARD_REMOTE_FEATURE_FLAG,
+    remoteFeatureFlag = ENABLED_REMOTE_FEATURE_FLAG,
     featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
     url = DYNAMIC_CARD_URL,
     action = DYNAMIC_CARD_ACTION,
@@ -47,7 +52,7 @@ private val DYNAMIC_CARD_MODEL = CardModel.DynamicCardsModel.DynamicCardModel(
 private val DYNAMIC_CARD_MODEL_INVALID_ACTION_TITLE = CardModel.DynamicCardsModel.DynamicCardModel(
     id = DYNAMIC_CARD_ID,
     title = DYNAMIC_CARD_TITLE,
-    remoteFeatureFlag = DYNAMIC_CARD_REMOTE_FEATURE_FLAG,
+    remoteFeatureFlag = ENABLED_REMOTE_FEATURE_FLAG,
     featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
     url = DYNAMIC_CARD_URL,
     action = null,
@@ -58,7 +63,7 @@ private val DYNAMIC_CARD_MODEL_INVALID_ACTION_TITLE = CardModel.DynamicCardsMode
 private val DYNAMIC_CARD_MODEL_INVALID_ACTION_TITLE_AND_URL = CardModel.DynamicCardsModel.DynamicCardModel(
     id = DYNAMIC_CARD_ID,
     title = DYNAMIC_CARD_TITLE,
-    remoteFeatureFlag = DYNAMIC_CARD_REMOTE_FEATURE_FLAG,
+    remoteFeatureFlag = ENABLED_REMOTE_FEATURE_FLAG,
     featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
     url = "",
     action = null,
@@ -69,7 +74,7 @@ private val DYNAMIC_CARD_MODEL_INVALID_ACTION_TITLE_AND_URL = CardModel.DynamicC
 private val DYNAMIC_CARD_MODEL_INVALID_URL = CardModel.DynamicCardsModel.DynamicCardModel(
     id = DYNAMIC_CARD_ID,
     title = DYNAMIC_CARD_TITLE,
-    remoteFeatureFlag = DYNAMIC_CARD_REMOTE_FEATURE_FLAG,
+    remoteFeatureFlag = ENABLED_REMOTE_FEATURE_FLAG,
     featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
     url = null,
     action = DYNAMIC_CARD_ACTION,
@@ -77,6 +82,39 @@ private val DYNAMIC_CARD_MODEL_INVALID_URL = CardModel.DynamicCardsModel.Dynamic
     rows = listOf(DYNAMIC_CARD_ROW_MODEL)
 )
 
+private val DYNAMIC_CARD_MODEL_DISABLED_REMOTELY = CardModel.DynamicCardsModel.DynamicCardModel(
+    id = DYNAMIC_CARD_ID,
+    title = DYNAMIC_CARD_TITLE,
+    remoteFeatureFlag = DISABLED_REMOTE_FEATURE_FLAG,
+    featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
+    url = DYNAMIC_CARD_URL,
+    action = DYNAMIC_CARD_ACTION,
+    order = CardModel.DynamicCardsModel.CardOrder.fromString(DYNAMIC_CARD_ORDER),
+    rows = listOf(DYNAMIC_CARD_ROW_MODEL)
+)
+
+private val DYNAMIC_CARD_MODEL_WITH_EMPTY_REMOTE_FLAG = CardModel.DynamicCardsModel.DynamicCardModel(
+    id = DYNAMIC_CARD_ID,
+    title = DYNAMIC_CARD_TITLE,
+    remoteFeatureFlag = "",
+    featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
+    url = DYNAMIC_CARD_URL,
+    action = DYNAMIC_CARD_ACTION,
+    order = CardModel.DynamicCardsModel.CardOrder.fromString(DYNAMIC_CARD_ORDER),
+    rows = listOf(DYNAMIC_CARD_ROW_MODEL)
+)
+
+private val DYNAMIC_CARD_MODEL_WITH_UNKNOWN_REMOTE_FLAG = CardModel.DynamicCardsModel.DynamicCardModel(
+    id = DYNAMIC_CARD_ID,
+    title = DYNAMIC_CARD_TITLE,
+    remoteFeatureFlag = UNKNOWN_REMOTE_FEATURE_FLAG,
+    featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
+    url = DYNAMIC_CARD_URL,
+    action = DYNAMIC_CARD_ACTION,
+    order = CardModel.DynamicCardsModel.CardOrder.fromString(DYNAMIC_CARD_ORDER),
+    rows = listOf(DYNAMIC_CARD_ROW_MODEL)
+)
+
 @ExperimentalCoroutinesApi
 class DynamicCardsBuilderTest : BaseUnitTest() {
     private lateinit var dynamicCardsBuilder: DynamicCardsBuilder
@@ -87,14 +125,27 @@ class DynamicCardsBuilderTest : BaseUnitTest() {
     @Mock
     private lateinit var deepLinkHandlers: DeepLinkHandlers
 
+    @Mock
+    private lateinit var dynamicDashboardCardsFeatureConfig: DynamicDashboardCardsFeatureConfig
+
+    @Mock
+    private lateinit var featureFlagConfig: FeatureFlagConfig
+
     @Before
     fun setUp() {
-        dynamicCardsBuilder = DynamicCardsBuilder(urlUtils, deepLinkHandlers)
+        dynamicCardsBuilder =
+            DynamicCardsBuilder(urlUtils, deepLinkHandlers, dynamicDashboardCardsFeatureConfig, featureFlagConfig)
         whenever(urlUtils.isValidUrlAndHostNotNull(DYNAMIC_CARD_URL)).thenReturn(true)
+        whenever(dynamicDashboardCardsFeatureConfig.isEnabled()).thenReturn(true)
+        whenever(featureFlagConfig.isEnabled(ENABLED_REMOTE_FEATURE_FLAG)).thenReturn(true)
+        whenever(featureFlagConfig.getString(ENABLED_REMOTE_FEATURE_FLAG)).thenReturn(ENABLED_REMOTE_FEATURE_FLAG)
+        whenever(featureFlagConfig.isEnabled(DISABLED_REMOTE_FEATURE_FLAG)).thenReturn(false)
+        whenever(featureFlagConfig.getString(DISABLED_REMOTE_FEATURE_FLAG)).thenReturn(DISABLED_REMOTE_FEATURE_FLAG)
+        whenever(featureFlagConfig.getString(UNKNOWN_REMOTE_FEATURE_FLAG)).thenReturn("")
     }
 
     @Test
-    fun testBuild() {
+    fun `WHEN the parameters contain an eligible card THEN all the card fields are copied to the card ui`() {
         val expectedCards = listOf(
             MySiteCardAndItem.Card.Dynamic(
                 id = DYNAMIC_CARD_ID,
@@ -124,11 +175,11 @@ class DynamicCardsBuilderTest : BaseUnitTest() {
             onHideMenuItemClick = mock(),
         )
         val dynamicCards = dynamicCardsBuilder.build(builderParams, CardModel.DynamicCardsModel.CardOrder.TOP)
-        assertThat(requireNotNull(dynamicCards).size).isEqualTo(1)
+        assertEquals(requireNotNull(dynamicCards).size, 1)
         assertEquals(expectedCards[0].id, dynamicCards[0].id)
         assertEquals(expectedCards[0].title, dynamicCards[0].title)
         assertEquals(expectedCards[0].image, dynamicCards[0].image)
-        assertThat(expectedCards[0].rows.size).isEqualTo(1)
+        assertEquals(expectedCards[0].rows.size, 1)
         assertEquals(expectedCards[0].rows, dynamicCards[0].rows)
         assertThat(dynamicCards[0].action).isInstanceOf(MySiteCardAndItem.Card.Dynamic.ActionSource.Button::class.java)
         val expected = expectedCards[0].action as? MySiteCardAndItem.Card.Dynamic.ActionSource.Button
@@ -138,7 +189,7 @@ class DynamicCardsBuilderTest : BaseUnitTest() {
     }
 
     @Test
-    fun testBuildWithInvalidActionTitle() {
+    fun `WHEN the card has invalid action title THEN the card has no action button but is actionable`() {
         val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
             dynamicCards = CardModel.DynamicCardsModel(
                 dynamicCards = listOf(DYNAMIC_CARD_MODEL_INVALID_ACTION_TITLE)
@@ -155,7 +206,7 @@ class DynamicCardsBuilderTest : BaseUnitTest() {
     }
 
     @Test
-    fun testBuildWithInvalidActionTitleAndUrl() {
+    fun `WHEN the card has invalid action title and url THEN the card is not actionable`() {
         val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
             dynamicCards = CardModel.DynamicCardsModel(
                 dynamicCards = listOf(DYNAMIC_CARD_MODEL_INVALID_ACTION_TITLE_AND_URL)
@@ -170,7 +221,7 @@ class DynamicCardsBuilderTest : BaseUnitTest() {
     }
 
     @Test
-    fun testBuildWithInvalidUrl() {
+    fun `WHEN the card has invalid url THEN the card is not actionable`() {
         val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
             dynamicCards = CardModel.DynamicCardsModel(
                 dynamicCards = listOf(DYNAMIC_CARD_MODEL_INVALID_URL)
@@ -185,10 +236,10 @@ class DynamicCardsBuilderTest : BaseUnitTest() {
     }
 
     @Test
-    fun testBuildWithEmptyPosition() {
+    fun `WHEN there are no cards for the requested order position THEN no cards are shown`() {
         val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
             dynamicCards = CardModel.DynamicCardsModel(
-                dynamicCards = listOf(DYNAMIC_CARD_MODEL_INVALID_ACTION_TITLE_AND_URL)
+                dynamicCards = listOf(DYNAMIC_CARD_MODEL)
             ),
             onActionClick = mock(),
             onMoreMenuClick = mock(),
@@ -199,7 +250,7 @@ class DynamicCardsBuilderTest : BaseUnitTest() {
     }
 
     @Test
-    fun testBuildWithInvalidParams() {
+    fun `WHEN the dynamic cards are null THEN then no dynamic cards are shown`() {
         val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
             dynamicCards = null,
             onActionClick = mock(),
@@ -209,4 +260,46 @@ class DynamicCardsBuilderTest : BaseUnitTest() {
         val dynamicCards = dynamicCardsBuilder.build(builderParams, CardModel.DynamicCardsModel.CardOrder.TOP)
         assertThat(dynamicCards).isNull()
     }
+
+    @Test
+    fun `WHEN the card remote feature flag is disabled THEN the card is not visible`() {
+        val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+            dynamicCards = CardModel.DynamicCardsModel(
+                dynamicCards = listOf(DYNAMIC_CARD_MODEL_DISABLED_REMOTELY)
+            ),
+            onActionClick = mock(),
+            onMoreMenuClick = mock(),
+            onHideMenuItemClick = mock(),
+        )
+        val dynamicCards = dynamicCardsBuilder.build(builderParams, CardModel.DynamicCardsModel.CardOrder.TOP)
+        assertThat(requireNotNull(dynamicCards)).isEmpty()
+    }
+
+    @Test
+    fun `WHEN the card remote feature flag is empty THEN the card is visible`() {
+        val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+            dynamicCards = CardModel.DynamicCardsModel(
+                dynamicCards = listOf(DYNAMIC_CARD_MODEL_WITH_EMPTY_REMOTE_FLAG)
+            ),
+            onActionClick = mock(),
+            onMoreMenuClick = mock(),
+            onHideMenuItemClick = mock(),
+        )
+        val dynamicCards = dynamicCardsBuilder.build(builderParams, CardModel.DynamicCardsModel.CardOrder.TOP)
+        assertEquals(requireNotNull(dynamicCards).size, 1)
+    }
+
+    @Test
+    fun `WHEN the card remote feature flag does not exist THEN the card is visible`() {
+        val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+            dynamicCards = CardModel.DynamicCardsModel(
+                dynamicCards = listOf(DYNAMIC_CARD_MODEL_WITH_UNKNOWN_REMOTE_FLAG)
+            ),
+            onActionClick = mock(),
+            onMoreMenuClick = mock(),
+            onHideMenuItemClick = mock(),
+        )
+        val dynamicCards = dynamicCardsBuilder.build(builderParams, CardModel.DynamicCardsModel.CardOrder.TOP)
+        assertEquals(requireNotNull(dynamicCards).size, 1)
+    }
 }