Repository: element-hq/element-x-android (language: kotlin)

Issue:
Add feature flag to disable caption sending / adding
The legacy applications cannot render captions on the timeline, so we're going to add a temporary disabled feature flag.

When the flag is off:
- [x] hide the caption form on the media preview screen
- [x] hide the "Add caption" from the message menu


Discussion hints:
nan

Pull request: Add feature flag to temporary disable sending caption by default in production
Closes #3950

Android friend of https://github.com/element-hq/element-x-ios/pull/3560

Test command: ./gradlew :features:messages:impl:testDebugUnitTest --tests "io.element.android.features.messages.impl.actionlist.ActionListPresenterTest" --tests "io.element.android.features.messages.impl.attachments.AttachmentsPreviewPresenterTest"

Fix patch:
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
index e228f3eb736..f2b97769c03 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
@@ -37,6 +37,8 @@ import io.element.android.features.messages.impl.timeline.model.event.canBeForwa
 import io.element.android.features.messages.impl.timeline.model.event.canReact
 import io.element.android.libraries.architecture.Presenter
 import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
 import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.matrix.api.room.MatrixRoom
 import io.element.android.libraries.preferences.api.store.AppPreferencesStore
@@ -60,6 +62,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
     private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
     private val room: MatrixRoom,
     private val userSendFailureFactory: VerifiedUserSendFailureFactory,
+    private val featureFlagService: FeatureFlagService,
 ) : ActionListPresenter {
     @AssistedFactory
     @ContributesBinding(RoomScope::class)
@@ -134,7 +137,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
         }
     }
 
-    private fun buildActions(
+    private suspend fun buildActions(
         timelineItem: TimelineItem.Event,
         usersEventPermissions: UserEventPermissions,
         isDeveloperModeEnabled: Boolean,
@@ -157,7 +160,9 @@ class DefaultActionListPresenter @AssistedInject constructor(
                 if (timelineItem.content is TimelineItemEventContentWithAttachment) {
                     // Caption
                     if (timelineItem.content.caption == null) {
-                        add(TimelineItemAction.AddCaption)
+                        if (featureFlagService.isFeatureEnabled(FeatureFlags.MediaCaptionCreation)) {
+                            add(TimelineItemAction.AddCaption)
+                        }
                     } else {
                         add(TimelineItemAction.EditCaption)
                         add(TimelineItemAction.RemoveCaption)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
index 69d28d8f202..606e91863d3 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.attachments.preview
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
@@ -46,7 +47,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
     private val mediaSender: MediaSender,
     private val permalinkBuilder: PermalinkBuilder,
     private val temporaryUriDeleter: TemporaryUriDeleter,
-    private val featureFlagsService: FeatureFlagService,
+    private val featureFlagService: FeatureFlagService,
 ) : Presenter<AttachmentsPreviewState> {
     @AssistedFactory
     interface Factory {
@@ -72,6 +73,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
         val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
 
         val userSentAttachment = remember { mutableStateOf(false) }
+        val allowCaption by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation).collectAsState(initial = false)
 
         val mediaUploadInfoState = remember { mutableStateOf<AsyncData<MediaUploadInfo>>(AsyncData.Uninitialized) }
         LaunchedEffect(Unit) {
@@ -112,7 +114,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
         fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
             when (attachmentsPreviewEvents) {
                 is AttachmentsPreviewEvents.SendAttachment -> coroutineScope.launch {
-                    val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
+                    val useSendQueue = featureFlagService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
                     userSentAttachment.value = true
                     val instantSending = mediaUploadInfoState.value.isReady() && useSendQueue
                     sendActionState.value = if (instantSending) {
@@ -142,6 +144,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
             attachment = attachment,
             sendActionState = sendActionState.value,
             textEditorState = textEditorState,
+            allowCaption = allowCaption,
             eventSink = ::handleEvents
         )
     }
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt
index 0e9724c4606..0878b52f803 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt
@@ -15,11 +15,9 @@ data class AttachmentsPreviewState(
     val attachment: Attachment,
     val sendActionState: SendActionState,
     val textEditorState: TextEditorState,
+    val allowCaption: Boolean,
     val eventSink: (AttachmentsPreviewEvents) -> Unit
-) {
-    // Keep the val to eventually set to false for some mimetypes.
-    val allowCaption: Boolean = true
-}
+)
 
 @Immutable
 sealed interface SendActionState {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt
index 1c55245e469..f7e215fa222 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt
@@ -29,6 +29,7 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
             anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing),
             anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)),
             anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))),
+            anAttachmentsPreviewState(allowCaption = false),
         )
 }
 
@@ -36,11 +37,13 @@ fun anAttachmentsPreviewState(
     mediaInfo: MediaInfo = anImageMediaInfo(),
     textEditorState: TextEditorState = aTextEditorStateMarkdown(),
     sendActionState: SendActionState = SendActionState.Idle,
+    allowCaption: Boolean = true,
 ) = AttachmentsPreviewState(
     attachment = Attachment.Media(
         localMedia = LocalMedia("file://path".toUri(), mediaInfo),
     ),
     sendActionState = sendActionState,
     textEditorState = textEditorState,
+    allowCaption = allowCaption,
     eventSink = {}
 )
diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
index 490c12ebe0d..42d9f309207 100644
--- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
+++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
@@ -140,4 +140,11 @@ enum class FeatureFlags(
         defaultValue = { buildMeta -> buildMeta.buildType != BuildType.RELEASE },
         isFinished = false,
     ),
+    MediaCaptionCreation(
+        key = "feature.media_caption_creation",
+        title = "Allow creation of media captions",
+        description = null,
+        defaultValue = { buildMeta -> buildMeta.buildType != BuildType.RELEASE },
+        isFinished = false,
+    ),
 }


Test patch:
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
index b79468f8200..aedd0cdc14b 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
@@ -26,6 +26,8 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
 import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
 import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
 import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
 import io.element.android.libraries.matrix.api.room.MatrixRoom
 import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
 import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -558,6 +560,57 @@ class ActionListPresenterTest {
         }
     }
 
+    @Test
+    fun `present - compute for a media item - caption disabled`() = runTest {
+        val presenter = createActionListPresenter(
+            isDeveloperModeEnabled = true,
+            isPinFeatureEnabled = true,
+            allowCaption = false,
+        )
+        moleculeFlow(RecompositionMode.Immediate) {
+            presenter.present()
+        }.test {
+            val initialState = awaitItem()
+            val messageEvent = aMessageEvent(
+                isMine = true,
+                isEditable = true,
+                content = aTimelineItemImageContent(),
+            )
+            initialState.eventSink.invoke(
+                ActionListEvents.ComputeForMessage(
+                    event = messageEvent,
+                    userEventPermissions = aUserEventPermissions(
+                        canRedactOwn = true,
+                        canRedactOther = false,
+                        canSendMessage = true,
+                        canSendReaction = true,
+                        canPinUnpin = true,
+                    ),
+                )
+            )
+            val successState = awaitItem()
+            assertThat(successState.target).isEqualTo(
+                ActionListState.Target.Success(
+                    event = messageEvent,
+                    displayEmojiReactions = true,
+                    verifiedUserSendFailure = VerifiedUserSendFailure.None,
+                    actions = persistentListOf(
+                        TimelineItemAction.Reply,
+                        TimelineItemAction.Forward,
+                        // Not here
+                        // TimelineItemAction.AddCaption,
+                        TimelineItemAction.Pin,
+                        TimelineItemAction.CopyLink,
+                        TimelineItemAction.ViewSource,
+                        TimelineItemAction.Redact,
+                    )
+                )
+            )
+            initialState.eventSink.invoke(ActionListEvents.Clear)
+            assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
+        }
+    }
+
     @Test
     fun `present - compute for a media with caption item`() = runTest {
         val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
@@ -1151,6 +1204,7 @@ private fun createActionListPresenter(
     isDeveloperModeEnabled: Boolean,
     isPinFeatureEnabled: Boolean,
     room: MatrixRoom = FakeMatrixRoom(),
+    allowCaption: Boolean = true,
 ): ActionListPresenter {
     val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled)
     return DefaultActionListPresenter(
@@ -1158,6 +1212,11 @@ private fun createActionListPresenter(
         appPreferencesStore = preferencesStore,
         isPinnedMessagesFeatureEnabled = { isPinFeatureEnabled },
         room = room,
-        userSendFailureFactory = VerifiedUserSendFailureFactory(room)
+        userSendFailureFactory = VerifiedUserSendFailureFactory(room),
+        featureFlagService = FakeFeatureFlagService(
+            initialState = mapOf(
+                FeatureFlags.MediaCaptionCreation.key to allowCaption,
+            ),
+        )
     )
 }
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
index 48e5c208b6c..095b770f082 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
@@ -64,6 +64,28 @@ class AttachmentsPreviewPresenterTest {
 
     private val mockMediaUrl: Uri = mockk("localMediaUri")
 
+    @Test
+    fun `present - initial state`() = runTest {
+        createAttachmentsPreviewPresenter().test {
+           skipItems(1)
+            val initialState = awaitItem()
+            assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
+            assertThat(initialState.allowCaption).isTrue()
+        }
+    }
+
+    @Test
+    fun `present - initial state - caption not allowed`() = runTest {
+        createAttachmentsPreviewPresenter(
+            allowCaption = false,
+        ).test {
+            skipItems(1)
+            val initialState = awaitItem()
+            assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
+            assertThat(initialState.allowCaption).isFalse()
+        }
+    }
+
     @Test
     fun `present - send media success scenario`() = runTest {
         val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
@@ -420,6 +442,7 @@ class AttachmentsPreviewPresenterTest {
         temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
         onDoneListener: OnDoneListener = OnDoneListener { lambdaError() },
         mediaUploadOnSendQueueEnabled: Boolean = true,
+        allowCaption: Boolean = true,
     ): AttachmentsPreviewPresenter {
         return AttachmentsPreviewPresenter(
             attachment = aMediaAttachment(localMedia),
@@ -427,8 +450,11 @@ class AttachmentsPreviewPresenterTest {
             mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
             permalinkBuilder = permalinkBuilder,
             temporaryUriDeleter = temporaryUriDeleter,
-            featureFlagsService = FakeFeatureFlagService(
-                initialState = mapOf(FeatureFlags.MediaUploadOnSendQueue.key to mediaUploadOnSendQueueEnabled),
+            featureFlagService = FakeFeatureFlagService(
+                initialState = mapOf(
+                    FeatureFlags.MediaUploadOnSendQueue.key to mediaUploadOnSendQueueEnabled,
+                    FeatureFlags.MediaCaptionCreation.key to allowCaption,
+                ),
             )
         )
     }
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_7_en.png
new file mode 100644
index 00000000000..6920a089ef4
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_7_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:507c7dffae1aabfa687174f1f964e2c40b004183b6bc3a70b56d764e0d308b47
+size 392923