Support XEP-0425 message moderation

Stephen Paul Weber created

Change summary

cheogram.doap                                                            |  7 
src/cheogram/res/values/strings.xml                                      |  3 
src/main/java/eu/siacs/conversations/entities/Conversation.java          | 13 
src/main/java/eu/siacs/conversations/entities/Message.java               |  6 
src/main/java/eu/siacs/conversations/generator/IqGenerator.java          | 14 
src/main/java/eu/siacs/conversations/parser/MessageParser.java           | 18 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java | 12 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java        | 14 
src/main/java/eu/siacs/conversations/ui/XmppActivity.java                | 11 
src/main/res/menu/message_context.xml                                    |  4 
10 files changed, 81 insertions(+), 21 deletions(-)

Detailed changes

cheogram.doap 🔗

@@ -60,6 +60,13 @@
 	<implements rdf:resource="https://xmpp.org/rfcs/rfc6122.html"/>
 	<implements rdf:resource="https://xmpp.org/rfcs/rfc7590.html"/>
 
+	<implements>
+		<xmpp:SupportedXep>
+			<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0425.html"/>
+			<xmpp:status>complete</xmpp:status>
+			<xmpp:version>0.2.1</xmpp:version>
+		</xmpp:SupportedXep>
+	</implements>
 	<implements>
 		<xmpp:SupportedXep>
 			<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0428.html"/>

src/cheogram/res/values/strings.xml 🔗

@@ -31,4 +31,7 @@
     <string name="pref_dialler_integration_incoming_summary">Incoming calls from phone numbers may ring with your system dialler instead of this app\'s notification settings</string>
     <string name="save_as_sticker">Save as Sticker</string>
     <string name="sticker_name">Sticker Name</string>
+    <string name="moderate_message">Moderate</string>
+    <string name="moderate_reason">Moderation Resaon</string>
+    <string name="unable_to_moderate">Unable to Moderate</string>
 </resources>

src/main/java/eu/siacs/conversations/entities/Conversation.java 🔗

@@ -478,7 +478,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         return null;
     }
 
-    public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart, boolean received, boolean carbon) {
+    public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart) {
         synchronized (this.messages) {
             for (int i = this.messages.size() - 1; i >= 0; --i) {
                 final Message message = messages.get(i);
@@ -486,14 +486,9 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                 if (mcp == null) {
                     continue;
                 }
-                if (mcp.equals(counterpart) && ((message.getStatus() == Message.STATUS_RECEIVED) == received)
-                        && (carbon == message.isCarbon() || received)) {
-                    final boolean idMatch = id.equals(message.getRemoteMsgId()) || message.remoteMsgIdMatchInEdit(id);
-                    if (idMatch && !message.isFileOrImage() && !message.treatAsDownloadable()) {
-                        return message;
-                    } else {
-                        return null;
-                    }
+                if (mcp.equals(counterpart) || mcp.asBareJid().equals(counterpart)) {
+                    final boolean idMatch = id.equals(message.getRemoteMsgId()) || message.remoteMsgIdMatchInEdit(id) || (getMode() == MODE_MULTI && id.equals(message.getServerMsgId()));
+                    if (idMatch) return message;
                 }
             }
         }

src/main/java/eu/siacs/conversations/entities/Message.java 🔗

@@ -981,6 +981,10 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         }
     }
 
+    public void clearPayloads() {
+        this.payloads.clear();
+    }
+
     public void addPayload(Element el) {
         if (el == null) return;
 
@@ -1080,7 +1084,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
     }
 
     public synchronized void setFileParams(FileParams fileParams) {
-        if (this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
+        if (fileParams != null && this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
             fileParams.sims = this.fileParams.sims;
         }
         this.fileParams = fileParams;

src/main/java/eu/siacs/conversations/generator/IqGenerator.java 🔗

@@ -36,6 +36,7 @@ import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Bookmark;
 import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.DownloadableFile;
+import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.services.MessageArchiveService;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.xml.Element;
@@ -410,6 +411,19 @@ public class IqGenerator extends AbstractGenerator {
         return packet;
     }
 
+    public IqPacket moderateMessage(Account account, Message m, String reason) {
+        IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
+        packet.setTo(m.getConversation().getJid().asBareJid());
+        packet.setFrom(account.getJid());
+        Element moderate =
+            packet.addChild("apply-to", "urn:xmpp:fasten:0")
+                  .setAttribute("id", m.getServerMsgId())
+                  .addChild("moderate", "urn:xmpp:message-moderate:0");
+        moderate.addChild("retract", "urn:xmpp:message-retract:0");
+        moderate.addChild("reason", "urn:xmpp:message-moderate:0").setContent(reason);
+        return packet;
+    }
+
     public IqPacket requestHttpUploadSlot(Jid host, DownloadableFile file, String mime) {
         IqPacket packet = new IqPacket(IqPacket.TYPE.GET);
         packet.setTo(host);

src/main/java/eu/siacs/conversations/parser/MessageParser.java 🔗

@@ -429,11 +429,13 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
             }
         }
         String replacementId = replaceElement == null ? null : replaceElement.getAttribute("id");
+        boolean replaceAsRetraction = false;
         if (replacementId == null) {
             Element fasten = packet.findChild("apply-to", "urn:xmpp:fasten:0");
-            if (fasten != null && (fasten.findChild("retract", "urn:xmpp:message-retract:0") != null || fasten.findChild("urn:xmpp:message-moderate:0") != null)) {
+            if (fasten != null && (fasten.findChild("retract", "urn:xmpp:message-retract:0") != null || fasten.findChild("moderated", "urn:xmpp:message-moderate:0") != null)) {
                 replacementId = fasten.getAttribute("id");
                 packet.setBody("");
+                replaceAsRetraction = true;
             }
         }
         final LocalizedContent body = packet.getBody();
@@ -671,10 +673,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
             }
 
             if (replacementId != null && mXmppConnectionService.allowMessageCorrection()) {
-                final Message replacedMessage = conversation.findMessageWithRemoteIdAndCounterpart(replacementId,
-                        counterpart,
-                        message.getStatus() == Message.STATUS_RECEIVED,
-                        message.isCarbon());
+                final Message replacedMessage = conversation.findMessageWithRemoteIdAndCounterpart(replacementId, counterpart);
                 if (replacedMessage != null) {
                     final boolean fingerprintsMatch = replacedMessage.getFingerprint() == null
                             || replacedMessage.getFingerprint().equals(message.getFingerprint());
@@ -683,7 +682,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
                             && replacedMessage.getTrueCounterpart().asBareJid().equals(message.getTrueCounterpart().asBareJid());
                     final boolean mucUserMatches = query == null && replacedMessage.sameMucUser(message); //can not be checked when using mam
                     final boolean duplicate = conversation.hasDuplicateMessage(message);
-                    if (fingerprintsMatch && (trueCountersMatch || !conversationMultiMode || mucUserMatches) && !duplicate) {
+                    if (fingerprintsMatch && (trueCountersMatch || !conversationMultiMode || mucUserMatches || counterpart.isBareJid()) && !duplicate) {
                         Log.d(Config.LOGTAG, "replaced message '" + replacedMessage.getBody() + "' with '" + message.getBody() + "'");
                         synchronized (replacedMessage) {
                             final String uuid = replacedMessage.getUuid();
@@ -691,6 +690,13 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
                             replacedMessage.setBody(message.getBody());
                             replacedMessage.putEdited(replacedMessage.getRemoteMsgId(), replacedMessage.getServerMsgId());
                             replacedMessage.setRemoteMsgId(remoteMsgId);
+                            if (replaceAsRetraction) {
+                                mXmppConnectionService.getFileBackend().deleteFile(replacedMessage);
+                                mXmppConnectionService.evictPreview(message.getUuid());
+                                replacedMessage.clearPayloads();
+                                replacedMessage.setFileParams(null);
+                                replacedMessage.setDeleted(true);
+                            }
                             if (replacedMessage.getServerMsgId() == null || message.getServerMsgId() != null) {
                                 replacedMessage.setServerMsgId(message.getServerMsgId());
                             }

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -3555,6 +3555,18 @@ public class XmppConnectionService extends Service {
         });
     }
 
+    public void moderateMessage(final Account account, final Message m, final String reason) {
+        IqPacket request = this.mIqGenerator.moderateMessage(account, m, reason);
+                Log.d(Config.LOGTAG, "moderate: " + request);
+        sendIqPacket(account, request, (a, packet) -> {
+                Log.d(Config.LOGTAG, "moderate1: " + packet);
+            if (packet.getType() != IqPacket.TYPE.RESULT) {
+                showErrorToastInUi(R.string.unable_to_moderate);
+                Log.d(Config.LOGTAG, a.getJid().asBareJid() + " unable to moderate: " + packet);
+            }
+        });
+    }
+
     public void destroyRoom(final Conversation conversation, final OnRoomDestroy callback) {
         IqPacket request = new IqPacket(IqPacket.TYPE.SET);
         request.setTo(conversation.getJid().asBareJid());

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java 🔗

@@ -1002,7 +1002,7 @@ public class ConversationFragment extends XmppFragment
                 final String existingName = savingAsStickerName;
                 savingAsSticker = null;
                 savingAsStickerName = null;
-                activity.quickEdit(existingName, R.string.sticker_name, (name) -> {
+                activity.quickEdit(existingName, (name) -> {
                     try {
                         activity.xmppConnectionService.getFileBackend().copyFileToDocumentFile(activity, f, df, name);
                     } catch (final FileBackend.FileCopyException e) {
@@ -1012,7 +1012,7 @@ public class ConversationFragment extends XmppFragment
 
                     Toast.makeText(activity, "Sticker saved", Toast.LENGTH_SHORT).show();
                     return null;
-                });
+                }, R.string.sticker_name, false, false, true);
                 break;
             case REQUEST_TRUST_KEYS_TEXT:
                 sendMessage();
@@ -1431,6 +1431,7 @@ public class ConversationFragment extends XmppFragment
             MenuItem retryDecryption = menu.findItem(R.id.retry_decryption);
             MenuItem correctMessage = menu.findItem(R.id.correct_message);
             MenuItem retractMessage = menu.findItem(R.id.retract_message);
+            MenuItem moderateMessage = menu.findItem(R.id.moderate_message);
             MenuItem onlyThisThread = menu.findItem(R.id.only_this_thread);
             MenuItem shareWith = menu.findItem(R.id.share_with);
             MenuItem sendAgain = menu.findItem(R.id.send_again);
@@ -1461,6 +1462,9 @@ public class ConversationFragment extends XmppFragment
                 correctMessage.setVisible(true);
                 if (!relevantForCorrection.getBody().equals("") && !relevantForCorrection.getBody().equals(" ")) retractMessage.setVisible(true);
             }
+            if (conversation.getMode() == Conversation.MODE_MULTI && conversation.getMucOptions().getSelf().getRole().ranks(MucOptions.Role.MODERATOR) && conversation.getMucOptions().hasFeature("urn:xmpp:message-moderate:0")) {
+                moderateMessage.setVisible(true);
+            }
             if ((m.isFileOrImage() && !deleted && !receiving)
                     || (m.getType() == Message.TYPE_TEXT && !m.treatAsDownloadable())
                             && !unInitiatedButKnownSize
@@ -1543,6 +1547,12 @@ public class ConversationFragment extends XmppFragment
                     })
                     .setNegativeButton(R.string.no, null).show();
                 return true;
+            case R.id.moderate_message:
+                activity.quickEdit("Spam", (reason) -> {
+                    activity.xmppConnectionService.moderateMessage(conversation.getAccount(), selectedMessage, reason);
+                    return null;
+                }, R.string.moderate_reason, false, false, true);
+                return true;
             case R.id.copy_message:
                 ShareUtil.copyToClipboard(activity, selectedMessage);
                 return true;

src/main/java/eu/siacs/conversations/ui/XmppActivity.java 🔗

@@ -722,12 +722,17 @@ public abstract class XmppActivity extends ActionBarActivity {
         quickEdit(previousValue, callback, R.string.password, true, false);
     }
 
+    protected void quickEdit(final String previousValue, final OnValueEdited callback, final @StringRes int hint, boolean password, boolean permitEmpty) {
+        quickEdit(previousValue, callback, hint, password, permitEmpty, false);
+    }
+
     @SuppressLint("InflateParams")
-    private void quickEdit(final String previousValue,
+    protected void quickEdit(final String previousValue,
                            final OnValueEdited callback,
                            final @StringRes int hint,
                            boolean password,
-                           boolean permitEmpty) {
+                           boolean permitEmpty,
+                           boolean alwaysCallback) {
         AlertDialog.Builder builder = new AlertDialog.Builder(this);
         DialogQuickeditBinding binding = DataBindingUtil.inflate(getLayoutInflater(), R.layout.dialog_quickedit, null, false);
         if (password) {
@@ -748,7 +753,7 @@ public abstract class XmppActivity extends ActionBarActivity {
         dialog.show();
         View.OnClickListener clickListener = v -> {
             String value = binding.inputEditText.getText().toString();
-            if (!value.equals(previousValue) && (!value.trim().isEmpty() || permitEmpty)) {
+            if ((alwaysCallback || !value.equals(previousValue)) && (!value.trim().isEmpty() || permitEmpty)) {
                 String error = callback.onValueEdited(value);
                 if (error != null) {
                     binding.inputLayout.setError(error);

src/main/res/menu/message_context.xml 🔗

@@ -38,6 +38,10 @@
         android:id="@+id/retract_message"
         android:title="@string/retract_message"
         android:visible="false" />
+    <item
+        android:id="@+id/moderate_message"
+        android:title="@string/moderate_message"
+        android:visible="false" />
     <item
         android:id="@+id/copy_url"
         android:title="@string/copy_original_url"