Repository: thunderbird/thunderbird-android (language: kotlin)

Issue:
Message View Redesign: Add functionality for reply action icon
What needs to be done:
- When an identity of the current account is the only recipient, display the "reply" icon
- In all other cases display the "reply all" icon
- Remove the "primary action" from the overflow menu, so we don't display actions twice.

Note: We'll update the rules to select the primary action when we get to implement #3738. 


Discussion hints:
nan

Pull request: Message View Redesign: Reply actions in message view
Closes #6388

Test command: ./gradlew :app:core:testDebugUnitTest --tests "com.fsck.k9.message.ReplyActionStrategyTest"

Fix patch:
diff --git a/app/core/src/main/java/com/fsck/k9/message/ReplyActionStrategy.kt b/app/core/src/main/java/com/fsck/k9/message/ReplyActionStrategy.kt
new file mode 100644
index 00000000000..a208b982093
--- /dev/null
+++ b/app/core/src/main/java/com/fsck/k9/message/ReplyActionStrategy.kt
@@ -0,0 +1,36 @@
+package com.fsck.k9.message
+
+import com.fsck.k9.Account
+import com.fsck.k9.helper.ReplyToParser
+import com.fsck.k9.mail.Message
+
+/**
+ * Figures out which reply actions are available to the user.
+ */
+class ReplyActionStrategy(private val replyRoParser: ReplyToParser) {
+    fun getReplyActions(account: Account, message: Message): ReplyActions {
+        val recipientsToReplyTo = replyRoParser.getRecipientsToReplyTo(message, account)
+        val recipientsToReplyAllTo = replyRoParser.getRecipientsToReplyAllTo(message, account)
+
+        val replyToAllCount = recipientsToReplyAllTo.to.size + recipientsToReplyAllTo.cc.size
+        return if (replyToAllCount <= 1) {
+            if (recipientsToReplyTo.to.isEmpty()) {
+                ReplyActions(defaultAction = null)
+            } else {
+                ReplyActions(defaultAction = ReplyAction.REPLY)
+            }
+        } else {
+            ReplyActions(defaultAction = ReplyAction.REPLY_ALL, additionalActions = listOf(ReplyAction.REPLY))
+        }
+    }
+}
+
+data class ReplyActions(
+    val defaultAction: ReplyAction?,
+    val additionalActions: List<ReplyAction> = emptyList()
+)
+
+enum class ReplyAction {
+    REPLY,
+    REPLY_ALL
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageHeaderOnMenuItemClickListener.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageHeaderOnMenuItemClickListener.kt
new file mode 100644
index 00000000000..fc1126d01be
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageHeaderOnMenuItemClickListener.kt
@@ -0,0 +1,5 @@
+package com.fsck.k9.ui.messageview
+
+fun interface MessageHeaderOnMenuItemClickListener {
+    fun onMenuItemClick(itemId: Int)
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.java
index da82cc2dcf8..0329d528e5c 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.java
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.java
@@ -9,7 +9,7 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 import androidx.annotation.NonNull;
-import androidx.appcompat.widget.PopupMenu.OnMenuItemClickListener;
+
 import android.util.AttributeSet;
 import android.view.LayoutInflater;
 import android.view.View;
@@ -226,7 +226,7 @@ public void setOnToggleFlagClickListener(OnClickListener listener) {
         mHeaderContainer.setOnFlagListener(listener);
     }
 
-    public void setOnMenuItemClickListener(OnMenuItemClickListener listener) {
+    public void setOnMenuItemClickListener(MessageHeaderOnMenuItemClickListener listener) {
         mHeaderContainer.setOnMenuItemClickListener(listener);
     }
 
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt
index f7b7d012447..513b97cbf52 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt
@@ -158,9 +158,7 @@ class MessageViewFragment :
             onToggleFlagged()
         }
 
-        messageTopView.setOnMenuItemClickListener { item ->
-            onReplyMenuItemClicked(item.itemId)
-        }
+        messageTopView.setOnMenuItemClickListener(::onReplyMenuItemClicked)
 
         messageTopView.setOnDownloadButtonClickListener {
             onDownloadButtonClicked()
@@ -381,7 +379,7 @@ class MessageViewFragment :
         messageTopView.setSubject(displaySubject)
     }
 
-    private fun onReplyMenuItemClicked(itemId: Int): Boolean {
+    private fun onReplyMenuItemClicked(itemId: Int) {
         when (itemId) {
             R.id.reply -> onReply()
             R.id.reply_all -> onReplyAll()
@@ -390,8 +388,6 @@ class MessageViewFragment :
             R.id.share -> onSendAlternate()
             else -> error("Missing handler for reply menu item $itemId")
         }
-
-        return true
     }
 
     private fun onDownloadButtonClicked() {
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/view/KoinModule.kt b/app/ui/legacy/src/main/java/com/fsck/k9/view/KoinModule.kt
index d86dd2b718c..269d220fd9c 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/view/KoinModule.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/view/KoinModule.kt
@@ -1,9 +1,13 @@
 package com.fsck.k9.view
 
+import com.fsck.k9.helper.ReplyToParser
+import com.fsck.k9.message.ReplyActionStrategy
 import com.fsck.k9.ui.helper.RelativeDateTimeFormatter
 import org.koin.dsl.module
 
 val viewModule = module {
     single { WebViewConfigProvider(themeManager = get()) }
     factory { RelativeDateTimeFormatter(context = get(), clock = get()) }
+    factory { ReplyToParser() }
+    factory { ReplyActionStrategy(replyRoParser = get()) }
 }
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java b/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java
index 94afcd61d59..433051a0221 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java
@@ -1,6 +1,8 @@
 package com.fsck.k9.view;
 
 
+import java.util.List;
+
 import android.content.Context;
 import android.content.res.ColorStateList;
 import android.util.AttributeSet;
@@ -12,9 +14,9 @@
 import android.widget.TextView;
 import android.widget.Toast;
 
+import androidx.annotation.DrawableRes;
 import androidx.annotation.NonNull;
 import androidx.appcompat.widget.PopupMenu;
-import androidx.appcompat.widget.PopupMenu.OnMenuItemClickListener;
 import com.fsck.k9.Account;
 import com.fsck.k9.DI;
 import com.fsck.k9.FontSizes;
@@ -26,8 +28,12 @@
 import com.fsck.k9.mail.Address;
 import com.fsck.k9.mail.Flag;
 import com.fsck.k9.mail.Message;
+import com.fsck.k9.message.ReplyAction;
+import com.fsck.k9.message.ReplyActionStrategy;
+import com.fsck.k9.message.ReplyActions;
 import com.fsck.k9.ui.R;
 import com.fsck.k9.ui.helper.RelativeDateTimeFormatter;
+import com.fsck.k9.ui.messageview.MessageHeaderOnMenuItemClickListener;
 import com.google.android.material.chip.Chip;
 import com.google.android.material.snackbar.Snackbar;
 
@@ -35,6 +41,7 @@
 public class MessageHeader extends LinearLayout implements OnClickListener, OnLongClickListener {
     private static final int DEFAULT_SUBJECT_LINES = 3;
 
+    private final ReplyActionStrategy replyActionStrategy = DI.get(ReplyActionStrategy.class);
     private final FontSizes fontSizes = K9.getFontSizes();
 
     private Chip accountChip;
@@ -46,11 +53,13 @@ public class MessageHeader extends LinearLayout implements OnClickListener, OnLo
     private TextView toView;
     private TextView toCountView;
     private TextView dateView;
+    private ImageView menuPrimaryActionView;
 
     private MessageHelper messageHelper;
     private RelativeDateTimeFormatter relativeDateTimeFormatter;
 
-    private OnMenuItemClickListener onMenuItemClickListener;
+    private MessageHeaderOnMenuItemClickListener onMenuItemClickListener;
+    private ReplyActions replyActions;
 
 
     public MessageHeader(Context context, AttributeSet attrs) {
@@ -85,7 +94,7 @@ protected void onFinishInflate() {
         subjectView.setOnClickListener(this);
         subjectView.setOnLongClickListener(this);
 
-        View menuPrimaryActionView = findViewById(R.id.menu_primary_action);
+        menuPrimaryActionView = findViewById(R.id.menu_primary_action);
         menuPrimaryActionView.setOnClickListener(this);
         menuPrimaryActionView.setOnLongClickListener(this);
 
@@ -102,17 +111,46 @@ public void onClick(View view) {
         if (id == R.id.subject) {
             toggleSubjectViewMaxLines();
         } else if (id == R.id.menu_primary_action) {
-            Snackbar.make(getRootView(), "TODO: Perform primary action", Snackbar.LENGTH_LONG).show();
+            performPrimaryReplyAction();
         } else if (id == R.id.menu_overflow) {
-            PopupMenu popupMenu = new PopupMenu(getContext(), view);
-            popupMenu.setOnMenuItemClickListener(onMenuItemClickListener);
-            popupMenu.inflate(R.menu.single_message_options);
-            popupMenu.show();
+            showOverflowMenu(view);
         } else if (id == R.id.participants_container) {
             Snackbar.make(getRootView(), "TODO: Display details popup", Snackbar.LENGTH_LONG).show();
         }
     }
 
+    private void performPrimaryReplyAction() {
+        ReplyAction defaultAction = replyActions.getDefaultAction();
+        if (defaultAction == null) {
+            return;
+        }
+
+        switch (defaultAction) {
+            case REPLY: {
+                onMenuItemClickListener.onMenuItemClick(R.id.reply);
+                break;
+            }
+            case REPLY_ALL: {
+                onMenuItemClickListener.onMenuItemClick(R.id.reply_all);
+                break;
+            }
+            default: {
+                throw new IllegalStateException("Unknown reply action: " + defaultAction);
+            }
+        }
+    }
+
+    private void showOverflowMenu(View view) {
+        PopupMenu popupMenu = new PopupMenu(getContext(), view);
+        popupMenu.setOnMenuItemClickListener(item -> {
+            onMenuItemClickListener.onMenuItemClick(item.getItemId());
+            return true;
+        });
+        popupMenu.inflate(R.menu.single_message_options);
+        setAdditionalReplyActions(popupMenu);
+        popupMenu.show();
+    }
+
     @Override
     public boolean onLongClick(View view) {
         int id = view.getId();
@@ -190,9 +228,53 @@ public void populate(final Message message, final Account account, boolean showS
             dateView.setText("");
         }
 
+        setReplyActions(message, account);
+
         setVisibility(View.VISIBLE);
     }
 
+    private void setReplyActions(Message message, Account account) {
+        ReplyActions replyActions = replyActionStrategy.getReplyActions(account, message);
+        this.replyActions = replyActions;
+
+        setDefaultReplyAction(replyActions.getDefaultAction());
+    }
+
+    private void setDefaultReplyAction(ReplyAction defaultAction) {
+        if (defaultAction == null) {
+            menuPrimaryActionView.setVisibility(View.GONE);
+        } else {
+            int replyIconResource = getReplyImageResource(defaultAction);
+            menuPrimaryActionView.setImageResource(replyIconResource);
+            menuPrimaryActionView.setVisibility(View.VISIBLE);
+        }
+    }
+
+    @DrawableRes
+    private int getReplyImageResource(@NonNull ReplyAction replyAction) {
+        switch (replyAction) {
+            case REPLY: {
+                return R.drawable.ic_reply;
+            }
+            case REPLY_ALL: {
+                return R.drawable.ic_reply_all;
+            }
+            default: {
+                throw new IllegalStateException("Unknown reply action: " + replyAction);
+            }
+        }
+    }
+
+    private void setAdditionalReplyActions(PopupMenu popupMenu) {
+        List<ReplyAction> additionalActions = replyActions.getAdditionalActions();
+        if (!additionalActions.contains(ReplyAction.REPLY)) {
+            popupMenu.getMenu().removeItem(R.id.reply);
+        }
+        if (!additionalActions.contains(ReplyAction.REPLY_ALL)) {
+            popupMenu.getMenu().removeItem(R.id.reply_all);
+        }
+    }
+
     public void setSubject(@NonNull String subject) {
         subjectView.setText(subject);
     }
@@ -221,7 +303,7 @@ private void setCryptoDisplayStatus(MessageCryptoDisplayStatus displayStatus) {
         cryptoStatusIcon.setColorFilter(color);
     }
 
-    public void setOnMenuItemClickListener(OnMenuItemClickListener onMenuItemClickListener) {
+    public void setOnMenuItemClickListener(MessageHeaderOnMenuItemClickListener onMenuItemClickListener) {
         this.onMenuItemClickListener = onMenuItemClickListener;
     }
 }
diff --git a/app/ui/legacy/src/main/res/drawable/ic_reply.xml b/app/ui/legacy/src/main/res/drawable/ic_reply.xml
new file mode 100644
index 00000000000..bd35ee9e7e1
--- /dev/null
+++ b/app/ui/legacy/src/main/res/drawable/ic_reply.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?attr/colorControlNormal"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M10,9V5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z" />
+</vector>


Test patch:
diff --git a/app/core/src/test/java/com/fsck/k9/message/ReplyActionStrategyTest.kt b/app/core/src/test/java/com/fsck/k9/message/ReplyActionStrategyTest.kt
new file mode 100644
index 00000000000..e366859072e
--- /dev/null
+++ b/app/core/src/test/java/com/fsck/k9/message/ReplyActionStrategyTest.kt
@@ -0,0 +1,111 @@
+package com.fsck.k9.message
+
+import com.fsck.k9.Account
+import com.fsck.k9.Identity
+import com.fsck.k9.helper.ReplyToParser
+import com.fsck.k9.mail.buildMessage
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+private const val IDENTITY_EMAIL_ADDRESS = "myself@domain.example"
+
+class ReplyActionStrategyTest {
+    private val account = createAccount()
+    private val replyActionStrategy = ReplyActionStrategy(ReplyToParser())
+
+    @Test
+    fun `message sent to only our identity`() {
+        val message = buildMessage {
+            header("From", "sender@domain.example")
+            header("To", IDENTITY_EMAIL_ADDRESS)
+        }
+
+        val replyActions = replyActionStrategy.getReplyActions(account, message)
+
+        assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY)
+        assertThat(replyActions.additionalActions).isEmpty()
+    }
+
+    @Test
+    fun `message sent to our identity and others`() {
+        val message = buildMessage {
+            header("From", "sender@domain.example")
+            header("To", "$IDENTITY_EMAIL_ADDRESS, other@domain.example")
+        }
+
+        val replyActions = replyActionStrategy.getReplyActions(account, message)
+
+        assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL)
+        assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY)
+    }
+
+    @Test
+    fun `message sent to our identity and others (CC)`() {
+        val message = buildMessage {
+            header("From", "sender@domain.example")
+            header("Cc", "$IDENTITY_EMAIL_ADDRESS, other@domain.example")
+        }
+
+        val replyActions = replyActionStrategy.getReplyActions(account, message)
+
+        assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL)
+        assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY)
+    }
+
+    @Test
+    fun `message sent to our identity and others (To+CC)`() {
+        val message = buildMessage {
+            header("From", "sender@domain.example")
+            header("To", IDENTITY_EMAIL_ADDRESS)
+            header("Cc", "other@domain.example")
+        }
+
+        val replyActions = replyActionStrategy.getReplyActions(account, message)
+
+        assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL)
+        assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY)
+    }
+
+    @Test
+    fun `message sent to our identity and others (CC+To)`() {
+        val message = buildMessage {
+            header("From", "sender@domain.example")
+            header("To", "other@domain.example")
+            header("Cc", IDENTITY_EMAIL_ADDRESS)
+        }
+
+        val replyActions = replyActionStrategy.getReplyActions(account, message)
+
+        assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL)
+        assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY)
+    }
+
+    @Test
+    fun `message where neither sender nor recipient addresses belong to account`() {
+        val message = buildMessage {
+            header("From", "sender@domain.example")
+            header("To", "recipient@domain.example")
+        }
+
+        val replyActions = replyActionStrategy.getReplyActions(account, message)
+
+        assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL)
+        assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY)
+    }
+
+    @Test
+    fun `message without any sender or recipient headers`() {
+        val message = buildMessage {}
+
+        val replyActions = replyActionStrategy.getReplyActions(account, message)
+
+        assertThat(replyActions.defaultAction).isNull()
+        assertThat(replyActions.additionalActions).isEmpty()
+    }
+
+    private fun createAccount(): Account {
+        return Account("00000000-0000-4000-0000-000000000000").apply {
+            identities += Identity(name = "Myself", email = IDENTITY_EMAIL_ADDRESS)
+        }
+    }
+}