basic support for XEP-0308: Last Message Correction. fixes #864

Daniel Gultsch created

Change summary

src/main/java/eu/siacs/conversations/entities/Conversation.java          | 20 
src/main/java/eu/siacs/conversations/entities/Message.java               | 38 
src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java    |  1 
src/main/java/eu/siacs/conversations/generator/MessageGenerator.java     |  3 
src/main/java/eu/siacs/conversations/parser/MessageParser.java           | 61 
src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java    | 14 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java | 13 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java        | 72 
src/main/java/eu/siacs/conversations/ui/EditMessage.java                 |  2 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java      | 15 
src/main/res/drawable-hdpi/ic_lock_black_18dp.png                        |  0 
src/main/res/drawable-hdpi/ic_lock_white_18dp.png                        |  0 
src/main/res/drawable-hdpi/ic_mode_edit_black_18dp.png                   |  0 
src/main/res/drawable-hdpi/ic_mode_edit_white_18dp.png                   |  0 
src/main/res/drawable-hdpi/ic_secure_indicator.png                       |  0 
src/main/res/drawable-hdpi/ic_secure_indicator_white.png                 |  0 
src/main/res/drawable-mdpi/ic_lock_black_18dp.png                        |  0 
src/main/res/drawable-mdpi/ic_lock_white_18dp.png                        |  0 
src/main/res/drawable-mdpi/ic_mode_edit_black_18dp.png                   |  0 
src/main/res/drawable-mdpi/ic_mode_edit_white_18dp.png                   |  0 
src/main/res/drawable-mdpi/ic_secure_indicator.png                       |  0 
src/main/res/drawable-mdpi/ic_secure_indicator_white.png                 |  0 
src/main/res/drawable-xhdpi/ic_lock_black_18dp.png                       |  0 
src/main/res/drawable-xhdpi/ic_lock_white_18dp.png                       |  0 
src/main/res/drawable-xhdpi/ic_mode_edit_black_18dp.png                  |  0 
src/main/res/drawable-xhdpi/ic_mode_edit_white_18dp.png                  |  0 
src/main/res/drawable-xhdpi/ic_secure_indicator.png                      |  0 
src/main/res/drawable-xhdpi/ic_secure_indicator_white.png                |  0 
src/main/res/drawable-xxhdpi/ic_lock_black_18dp.png                      |  0 
src/main/res/drawable-xxhdpi/ic_lock_white_18dp.png                      |  0 
src/main/res/drawable-xxhdpi/ic_mode_edit_black_18dp.png                 |  0 
src/main/res/drawable-xxhdpi/ic_mode_edit_white_18dp.png                 |  0 
src/main/res/drawable-xxhdpi/ic_secure_indicator.png                     |  0 
src/main/res/drawable-xxhdpi/ic_secure_indicator_white.png               |  0 
src/main/res/drawable-xxxhdpi/ic_lock_black_18dp.png                     |  0 
src/main/res/drawable-xxxhdpi/ic_lock_white_18dp.png                     |  0 
src/main/res/drawable-xxxhdpi/ic_mode_edit_black_18dp.png                |  0 
src/main/res/drawable-xxxhdpi/ic_mode_edit_white_18dp.png                |  0 
src/main/res/layout/message_received.xml                                 | 12 
src/main/res/layout/message_sent.xml                                     | 12 
src/main/res/menu/message_context.xml                                    |  4 
src/main/res/values/strings.xml                                          |  2 
42 files changed, 228 insertions(+), 41 deletions(-)

Detailed changes

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

@@ -82,6 +82,7 @@ public class Conversation extends AbstractEntity implements Blockable {
 	private ChatState mIncomingChatState = Config.DEFAULT_CHATSTATE;
 	private String mLastReceivedOtrMessageId = null;
 	private String mFirstMamReference = null;
+	private Message correctingMessage;
 
 	public boolean hasMessagesLeftOnServer() {
 		return messagesLeftOnServer;
@@ -226,6 +227,17 @@ public class Conversation extends AbstractEntity implements Blockable {
 		return null;
 	}
 
+	public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart) {
+		synchronized (this.messages) {
+			for(Message message : this.messages) {
+				if(id.equals(message.getRemoteMsgId()) && counterpart.equals(message.getCounterpart())) {
+					return message;
+				}
+			}
+		}
+		return null;
+	}
+
 	public Message findSentMessageWithUuid(String id) {
 		synchronized (this.messages) {
 			for (Message message : this.messages) {
@@ -294,6 +306,14 @@ public class Conversation extends AbstractEntity implements Blockable {
 		return getLongAttribute("last_clear_history", 0);
 	}
 
+	public void setCorrectingMessage(Message correctingMessage) {
+		this.correctingMessage = correctingMessage;
+	}
+
+	public Message getCorrectingMessage() {
+		return this.correctingMessage;
+	}
+
 	public interface OnMessageFound {
 		void onMessageFound(final Message message);
 	}

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

@@ -52,6 +52,7 @@ public class Message extends AbstractEntity {
 	public static final String STATUS = "status";
 	public static final String TYPE = "type";
 	public static final String CARBON = "carbon";
+	public static final String EDITED = "edited";
 	public static final String REMOTE_MSG_ID = "remoteMsgId";
 	public static final String SERVER_MSG_ID = "serverMsgId";
 	public static final String RELATIVE_FILE_PATH = "relativeFilePath";
@@ -71,6 +72,7 @@ public class Message extends AbstractEntity {
 	protected int status;
 	protected int type;
 	protected boolean carbon = false;
+	protected String edited = null;
 	protected String relativeFilePath;
 	protected boolean read = true;
 	protected String remoteMsgId = null;
@@ -104,7 +106,8 @@ public class Message extends AbstractEntity {
 				null,
 				null,
 				null,
-				true);
+				true,
+				null);
 		this.conversation = conversation;
 	}
 
@@ -112,7 +115,8 @@ public class Message extends AbstractEntity {
 					final Jid trueCounterpart, final String body, final long timeSent,
 					final int encryption, final int status, final int type, final boolean carbon,
 					final String remoteMsgId, final String relativeFilePath,
-					final String serverMsgId, final String fingerprint, final boolean read) {
+					final String serverMsgId, final String fingerprint, final boolean read,
+					final String edited) {
 		this.uuid = uuid;
 		this.conversationUuid = conversationUUid;
 		this.counterpart = counterpart;
@@ -128,6 +132,7 @@ public class Message extends AbstractEntity {
 		this.serverMsgId = serverMsgId;
 		this.axolotlFingerprint = fingerprint;
 		this.read = read;
+		this.edited = edited;
 	}
 
 	public static Message fromCursor(Cursor cursor) {
@@ -162,12 +167,13 @@ public class Message extends AbstractEntity {
 				cursor.getInt(cursor.getColumnIndex(ENCRYPTION)),
 				cursor.getInt(cursor.getColumnIndex(STATUS)),
 				cursor.getInt(cursor.getColumnIndex(TYPE)),
-				cursor.getInt(cursor.getColumnIndex(CARBON))>0,
+				cursor.getInt(cursor.getColumnIndex(CARBON)) > 0,
 				cursor.getString(cursor.getColumnIndex(REMOTE_MSG_ID)),
 				cursor.getString(cursor.getColumnIndex(RELATIVE_FILE_PATH)),
 				cursor.getString(cursor.getColumnIndex(SERVER_MSG_ID)),
 				cursor.getString(cursor.getColumnIndex(FINGERPRINT)),
-				cursor.getInt(cursor.getColumnIndex(READ)) > 0);
+				cursor.getInt(cursor.getColumnIndex(READ)) > 0,
+				cursor.getString(cursor.getColumnIndex(EDITED)));
 	}
 
 	public static Message createStatusMessage(Conversation conversation, String body) {
@@ -211,7 +217,8 @@ public class Message extends AbstractEntity {
 		values.put(RELATIVE_FILE_PATH, relativeFilePath);
 		values.put(SERVER_MSG_ID, serverMsgId);
 		values.put(FINGERPRINT, axolotlFingerprint);
-		values.put(READ,read);
+		values.put(READ,read ? 1 : 0);
+		values.put(EDITED, edited);
 		return values;
 	}
 
@@ -340,10 +347,22 @@ public class Message extends AbstractEntity {
 		this.carbon = carbon;
 	}
 
+	public void setEdited(String edited) {
+		this.edited = edited;
+	}
+
+	public boolean edited() {
+		return this.edited != null;
+	}
+
 	public void setTrueCounterpart(Jid trueCounterpart) {
 		this.trueCounterpart = trueCounterpart;
 	}
 
+	public Jid getTrueCounterpart() {
+		return this.trueCounterpart;
+	}
+
 	public Transferable getTransferable() {
 		return this.transferable;
 	}
@@ -421,6 +440,7 @@ public class Message extends AbstractEntity {
 						this.getEncryption() == message.getEncryption() &&
 						this.getCounterpart() != null &&
 						this.getCounterpart().equals(message.getCounterpart()) &&
+						this.edited() == message.edited() &&
 						(message.getTimeSent() - this.getTimeSent()) <= (Config.MESSAGE_MERGE_WINDOW * 1000) &&
 						!GeoHelper.isGeoUri(message.getBody()) &&
 						!GeoHelper.isGeoUri(this.body) &&
@@ -510,6 +530,14 @@ public class Message extends AbstractEntity {
 		}
 	}
 
+	public void setUuid(String uuid) {
+		this.uuid = uuid;
+	}
+
+	public String getEditedId() {
+		return edited;
+	}
+
 	public enum Decision {
 		MUST,
 		SHOULD,

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

@@ -31,6 +31,7 @@ public abstract class AbstractGenerator {
 			"urn:xmpp:avatar:metadata+notify",
 			"http://jabber.org/protocol/nick+notify",
 			"urn:xmpp:ping",
+			"urn:xmpp:message-correct:0",
 			"jabber:iq:version",
 			"http://jabber.org/protocol/chatstates",
 			AxolotlService.PEP_DEVICE_LIST+"+notify"};

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

@@ -47,6 +47,9 @@ public class MessageGenerator extends AbstractGenerator {
 		}
 		packet.setFrom(account.getJid());
 		packet.setId(message.getUuid());
+		if (message.edited()) {
+			packet.addChild("replace","urn:xmpp:message-correct:0").setAttribute("id",message.getEditedId());
+		}
 		return packet;
 	}
 

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

@@ -297,6 +297,8 @@ public class MessageParser extends AbstractParser implements
 		final String body = packet.getBody();
 		final Element mucUserElement = packet.findChild("x", "http://jabber.org/protocol/muc#user");
 		final String pgpEncrypted = packet.findChildContent("x", "jabber:x:encrypted");
+		final Element replaceElement = packet.findChild("replace","urn:xmpp:message-correct:0");
+		final String replacementId = replaceElement == null ? null : replaceElement.getAttribute("id");
 		final Element axolotlEncrypted = packet.findChild(XmppAxolotlMessage.CONTAINERTAG, AxolotlService.PEP_PREFIX);
 		int status;
 		final Jid counterpart;
@@ -390,6 +392,33 @@ public class MessageParser extends AbstractParser implements
 			} else {
 				updateLastseen(timestamp, account, packet.getFrom(), true);
 			}
+
+			if (replacementId != null) {
+				Message replacedMessage = conversation.findMessageWithRemoteIdAndCounterpart(replacementId, counterpart);
+				if (replacedMessage != null) {
+					final boolean fingerprintsMatch = replacedMessage.getAxolotlFingerprint() == null
+							|| replacedMessage.getAxolotlFingerprint().equals(message.getAxolotlFingerprint());
+					final boolean trueCountersMatch = replacedMessage.getTrueCounterpart() != null
+							&& replacedMessage.getTrueCounterpart().equals(message.getTrueCounterpart());
+					if (fingerprintsMatch && (trueCountersMatch || conversation.getMode() == Conversation.MODE_SINGLE)) {
+						Log.d(Config.LOGTAG, "replaced message '" + replacedMessage.getBody() + "' with '" + message.getBody() + "'");
+						replacedMessage.setBody(message.getBody());
+						replacedMessage.setEdited(replacedMessage.getRemoteMsgId());
+						replacedMessage.setRemoteMsgId(remoteMsgId);
+						if (replacedMessage.getStatus() == Message.STATUS_RECEIVED) {
+							replacedMessage.markUnread();
+						}
+						mXmppConnectionService.updateMessage(replacedMessage);
+						if (mXmppConnectionService.confirmMessages() && remoteMsgId != null && !isForwarded && !isTypeGroupChat) {
+							sendMessageReceipts(account, packet);
+						}
+						return;
+					} else {
+						Log.d(Config.LOGTAG,account.getJid().toBareJid()+": received message correction but verification didn't check out");
+					}
+				}
+			}
+
 			boolean checkForDuplicates = query != null
 					|| (isTypeGroupChat && packet.hasChild("delay","urn:xmpp:delay"))
 					|| message.getType() == Message.TYPE_PRIVATE;
@@ -420,20 +449,7 @@ public class MessageParser extends AbstractParser implements
 			}
 
 			if (mXmppConnectionService.confirmMessages() && remoteMsgId != null && !isForwarded && !isTypeGroupChat) {
-				ArrayList<String> receiptsNamespaces = new ArrayList<>();
-				if (packet.hasChild("markable", "urn:xmpp:chat-markers:0")) {
-					receiptsNamespaces.add("urn:xmpp:chat-markers:0");
-				}
-				if (packet.hasChild("request", "urn:xmpp:receipts")) {
-					receiptsNamespaces.add("urn:xmpp:receipts");
-				}
-				if (receiptsNamespaces.size() > 0) {
-					MessagePacket receipt = mXmppConnectionService.getMessageGenerator().received(account,
-							packet,
-							receiptsNamespaces,
-							packet.getType());
-					mXmppConnectionService.sendMessagePacket(account, receipt);
-				}
+				sendMessageReceipts(account, packet);
 			}
 
 			if (message.getStatus() == Message.STATUS_RECEIVED
@@ -524,4 +540,21 @@ public class MessageParser extends AbstractParser implements
 			contact.setPresenceName(nick);
 		}
 	}
+
+	private void sendMessageReceipts(Account account, MessagePacket packet) {
+		ArrayList<String> receiptsNamespaces = new ArrayList<>();
+		if (packet.hasChild("markable", "urn:xmpp:chat-markers:0")) {
+			receiptsNamespaces.add("urn:xmpp:chat-markers:0");
+		}
+		if (packet.hasChild("request", "urn:xmpp:receipts")) {
+			receiptsNamespaces.add("urn:xmpp:receipts");
+		}
+		if (receiptsNamespaces.size() > 0) {
+			MessagePacket receipt = mXmppConnectionService.getMessageGenerator().received(account,
+					packet,
+					receiptsNamespaces,
+					packet.getType());
+			mXmppConnectionService.sendMessagePacket(account, receipt);
+		}
+	}
 }

src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java 🔗

@@ -51,7 +51,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
 	private static DatabaseBackend instance = null;
 
 	private static final String DATABASE_NAME = "history";
-	private static final int DATABASE_VERSION = 23;
+	private static final int DATABASE_VERSION = 24;
 
 	private static String CREATE_CONTATCS_STATEMENT = "create table "
 			+ Contact.TABLENAME + "(" + Contact.ACCOUNT + " TEXT, "
@@ -161,6 +161,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
 				+ Message.SERVER_MSG_ID + " TEXT, "
 				+ Message.FINGERPRINT + " TEXT, "
 				+ Message.CARBON + " INTEGER, "
+				+ Message.EDITED + " TEXT, "
 				+ Message.READ + " NUMBER DEFAULT 1, "
 				+ Message.REMOTE_MSG_ID + " TEXT, FOREIGN KEY("
 				+ Message.CONVERSATION + ") REFERENCES "
@@ -370,6 +371,10 @@ public class DatabaseBackend extends SQLiteOpenHelper {
 		if (oldVersion < 23 && newVersion >= 23) {
 			db.execSQL(CREATE_DISCOVERY_RESULTS_STATEMENT);
 		}
+
+		if (oldVersion < 24 && newVersion >= 24) {
+			db.execSQL("ALTER TABLE " + Message.TABLENAME + " ADD COLUMN " + Message.EDITED + " TEXT");
+		}
 	}
 
 	public static synchronized DatabaseBackend getInstance(Context context) {
@@ -586,6 +591,13 @@ public class DatabaseBackend extends SQLiteOpenHelper {
 				+ "=?", args);
 	}
 
+	public void updateMessage(Message message, String uuid) {
+		SQLiteDatabase db = this.getWritableDatabase();
+		String[] args = {uuid};
+		db.update(Message.TABLENAME, message.getContentValues(), Message.UUID
+				+ "=?", args);
+	}
+
 	public void readRoster(Roster roster) {
 		SQLiteDatabase db = this.getReadableDatabase();
 		Cursor cursor;

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

@@ -841,8 +841,9 @@ public class XmppConnectionService extends Service implements OnPhoneContactsLoa
 		final Conversation conversation = message.getConversation();
 		account.deactivateGracePeriod();
 		MessagePacket packet = null;
-		final boolean addToConversation = conversation.getMode() != Conversation.MODE_MULTI
-				|| account.getServerIdentity() != XmppConnection.Identity.SLACK;
+		final boolean addToConversation = (conversation.getMode() != Conversation.MODE_MULTI
+				|| account.getServerIdentity() != XmppConnection.Identity.SLACK)
+				&& !message.edited();
 		boolean saveInDb = addToConversation;
 		message.setStatus(Message.STATUS_WAITING);
 
@@ -966,8 +967,12 @@ public class XmppConnectionService extends Service implements OnPhoneContactsLoa
 			if (addToConversation) {
 				conversation.add(message);
 			}
-			if (saveInDb && (message.getEncryption() == Message.ENCRYPTION_NONE || saveEncryptedMessages())) {
-				databaseBackend.createMessage(message);
+			if (message.getEncryption() == Message.ENCRYPTION_NONE || saveEncryptedMessages()) {
+				if (saveInDb) {
+					databaseBackend.createMessage(message);
+				} else if (message.edited()) {
+					databaseBackend.updateMessage(message, message.getEditedId());
+				}
 			}
 			updateConversationUi();
 		}

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

@@ -8,7 +8,6 @@ import android.content.ActivityNotFoundException;
 import android.content.Context;
 import android.content.DialogInterface;
 import android.content.Intent;
-import android.content.IntentSender;
 import android.content.IntentSender.SendIntentException;
 import android.os.Bundle;
 import android.support.annotation.Nullable;
@@ -40,6 +39,7 @@ import net.java.otr4j.session.SessionStatus;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.UUID;
 
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
@@ -51,7 +51,6 @@ import eu.siacs.conversations.entities.DownloadableFile;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.MucOptions;
 import eu.siacs.conversations.entities.Presence;
-import eu.siacs.conversations.entities.Presences;
 import eu.siacs.conversations.entities.Transferable;
 import eu.siacs.conversations.entities.TransferablePlaceholder;
 import eu.siacs.conversations.services.XmppConnectionService;
@@ -294,8 +293,14 @@ public class ConversationFragment extends Fragment implements EditMessage.Keyboa
 						activity.attachFile(ConversationActivity.ATTACHMENT_CHOICE_CHOOSE_IMAGE);
 						break;
 					case CANCEL:
-						if (conversation != null && conversation.getMode() == Conversation.MODE_MULTI) {
-							conversation.setNextCounterpart(null);
+						if (conversation != null) {
+							if (conversation.getCorrectingMessage() != null) {
+								conversation.setCorrectingMessage(null);
+								mEditMessage.getEditableText().clear();
+							}
+							if (conversation.getMode() == Conversation.MODE_MULTI) {
+								conversation.setNextCounterpart(null);
+							}
 							updateChatMsgHint();
 							updateSendButton();
 						}
@@ -330,12 +335,21 @@ public class ConversationFragment extends Fragment implements EditMessage.Keyboa
 		if (body.length() == 0 || this.conversation == null) {
 			return;
 		}
-		Message message = new Message(conversation, body, conversation.getNextEncryption());
-		if (conversation.getMode() == Conversation.MODE_MULTI) {
-			if (conversation.getNextCounterpart() != null) {
-				message.setCounterpart(conversation.getNextCounterpart());
-				message.setType(Message.TYPE_PRIVATE);
+		final Message message;
+		if (conversation.getCorrectingMessage() == null) {
+			message = new Message(conversation, body, conversation.getNextEncryption());
+			if (conversation.getMode() == Conversation.MODE_MULTI) {
+				if (conversation.getNextCounterpart() != null) {
+					message.setCounterpart(conversation.getNextCounterpart());
+					message.setType(Message.TYPE_PRIVATE);
+				}
 			}
+		} else {
+			message = conversation.getCorrectingMessage();
+			message.setBody(body);
+			message.setEdited(message.getUuid());
+			message.setUuid(UUID.randomUUID().toString());
+			conversation.setCorrectingMessage(null);
 		}
 		switch (conversation.getNextEncryption()) {
 			case Message.ENCRYPTION_OTR:
@@ -356,7 +370,9 @@ public class ConversationFragment extends Fragment implements EditMessage.Keyboa
 
 	public void updateChatMsgHint() {
 		final boolean multi = conversation.getMode() == Conversation.MODE_MULTI;
-		if (multi && conversation.getNextCounterpart() != null) {
+		if (conversation.getCorrectingMessage() != null) {
+			this.mEditMessage.setHint(R.string.send_corrected_message);
+		} else if (multi && conversation.getNextCounterpart() != null) {
 			this.mEditMessage.setHint(getString(
 					R.string.send_private_message_to,
 					conversation.getNextCounterpart().getResourcepart()));
@@ -487,8 +503,7 @@ public class ConversationFragment extends Fragment implements EditMessage.Keyboa
 	}
 
 	@Override
-	public void onCreateContextMenu(ContextMenu menu, View v,
-									ContextMenuInfo menuInfo) {
+	public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
 		synchronized (this.messageList) {
 			super.onCreateContextMenu(menu, v, menuInfo);
 			AdapterView.AdapterContextMenuInfo acmi = (AdapterContextMenuInfo) menuInfo;
@@ -503,6 +518,7 @@ public class ConversationFragment extends Fragment implements EditMessage.Keyboa
 			activity.getMenuInflater().inflate(R.menu.message_context, menu);
 			menu.setHeaderTitle(R.string.message_options);
 			MenuItem copyText = menu.findItem(R.id.copy_text);
+			MenuItem correctMessage = menu.findItem(R.id.correct_message);
 			MenuItem shareWith = menu.findItem(R.id.share_with);
 			MenuItem sendAgain = menu.findItem(R.id.send_again);
 			MenuItem copyUrl = menu.findItem(R.id.copy_url);
@@ -514,6 +530,11 @@ public class ConversationFragment extends Fragment implements EditMessage.Keyboa
 					&& m.treatAsDownloadable() != Message.Decision.MUST) {
 				copyText.setVisible(true);
 			}
+			if (m.getType() == Message.TYPE_TEXT
+					&& m.getStatus() != Message.STATUS_RECEIVED
+					&& !m.isCarbon()) {
+				correctMessage.setVisible(true);
+			}
 			if ((m.getType() != Message.TYPE_TEXT
 					&& m.getType() != Message.TYPE_PRIVATE
 					&& m.getTransferable() == null)
@@ -550,6 +571,9 @@ public class ConversationFragment extends Fragment implements EditMessage.Keyboa
 			case R.id.copy_text:
 				copyText(selectedMessage);
 				return true;
+			case R.id.correct_message:
+				correctMessage(selectedMessage);
+				return true;
 			case R.id.send_again:
 				resendMessage(selectedMessage);
 				return true;
@@ -652,6 +676,16 @@ public class ConversationFragment extends Fragment implements EditMessage.Keyboa
 		updateSendButton();
 	}
 
+	private void correctMessage(Message message) {
+		while(message.mergeable(message.next())) {
+			message = message.next();
+		}
+		this.conversation.setCorrectingMessage(message);
+		this.mEditMessage.getEditableText().clear();
+		this.mEditMessage.getEditableText().append(message.getBody());
+
+	}
+
 	protected void highlightInConference(String nick) {
 		String oldString = mEditMessage.getText().toString().trim();
 		if (oldString.isEmpty() || mEditMessage.getSelectionStart() == 0) {
@@ -958,9 +992,12 @@ public class ConversationFragment extends Fragment implements EditMessage.Keyboa
 		final Conversation c = this.conversation;
 		final SendButtonAction action;
 		final Presence.Status status;
-		final boolean empty = this.mEditMessage == null || this.mEditMessage.getText().length() == 0;
+		final String text = this.mEditMessage == null ? "" : this.mEditMessage.getText().toString();
+		final boolean empty = text.length() == 0;
 		final boolean conference = c.getMode() == Conversation.MODE_MULTI;
-		if (conference && !c.getAccount().httpUploadAvailable()) {
+		if (c.getCorrectingMessage() != null && (empty || text.equals(c.getCorrectingMessage().getBody()))) {
+			action = SendButtonAction.CANCEL;
+		} else if (conference && !c.getAccount().httpUploadAvailable()) {
 			if (empty && c.getNextCounterpart() != null) {
 				action = SendButtonAction.CANCEL;
 			} else {
@@ -1238,6 +1275,13 @@ public class ConversationFragment extends Fragment implements EditMessage.Keyboa
 		updateSendButton();
 	}
 
+	@Override
+	public void onTextChanged() {
+		if (conversation != null && conversation.getCorrectingMessage() != null) {
+			updateSendButton();
+		}
+	}
+
 	private int completionIndex = 0;
 	private int lastCompletionLength = 0;
 	private String incomplete;

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

@@ -69,6 +69,7 @@ public class EditMessage extends EditText {
 				this.isUserTyping = false;
 				this.keyboardListener.onTextDeleted();
 			}
+			this.keyboardListener.onTextChanged();
 		}
 	}
 
@@ -84,6 +85,7 @@ public class EditMessage extends EditText {
 		void onTypingStarted();
 		void onTypingStopped();
 		void onTextDeleted();
+		void onTextChanged();
 		boolean onTabPressed(boolean repeated);
 	}
 

src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java 🔗

@@ -123,6 +123,16 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 		if (viewHolder.indicatorReceived != null) {
 			viewHolder.indicatorReceived.setVisibility(View.GONE);
 		}
+
+		if (viewHolder.edit_indicator != null) {
+			if (message.edited()) {
+				viewHolder.edit_indicator.setVisibility(View.VISIBLE);
+				viewHolder.edit_indicator.setImageResource(darkBackground ? R.drawable.ic_mode_edit_white_18dp : R.drawable.ic_mode_edit_black_18dp);
+				viewHolder.edit_indicator.setAlpha(darkBackground ? 0.7f : 0.57f);
+			} else {
+				viewHolder.edit_indicator.setVisibility(View.GONE);
+			}
+		}
 		boolean multiReceived = message.getConversation().getMode() == Conversation.MODE_MULTI
 			&& message.getMergedStatus() <= Message.STATUS_RECEIVED;
 		if (message.getType() == Message.TYPE_IMAGE || message.getType() == Message.TYPE_FILE || message.getTransferable() != null) {
@@ -179,7 +189,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 		if (message.getEncryption() == Message.ENCRYPTION_NONE) {
 			viewHolder.indicator.setVisibility(View.GONE);
 		} else {
-			viewHolder.indicator.setImageResource(darkBackground ? R.drawable.ic_secure_indicator_white : R.drawable.ic_secure_indicator);
+			viewHolder.indicator.setImageResource(darkBackground ? R.drawable.ic_lock_white_18dp : R.drawable.ic_lock_black_18dp);
 			viewHolder.indicator.setVisibility(View.VISIBLE);
 			if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
 				XmppAxolotlSession.Trust trust = message.getConversation()
@@ -463,6 +473,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 						.findViewById(R.id.download_button);
 					viewHolder.indicator = (ImageView) view
 						.findViewById(R.id.security_indicator);
+					viewHolder.edit_indicator = (ImageView) view.findViewById(R.id.edit_indicator);
 					viewHolder.image = (ImageView) view
 						.findViewById(R.id.message_image);
 					viewHolder.messageBody = (TextView) view
@@ -483,6 +494,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 						.findViewById(R.id.download_button);
 					viewHolder.indicator = (ImageView) view
 						.findViewById(R.id.security_indicator);
+					viewHolder.edit_indicator = (ImageView) view.findViewById(R.id.edit_indicator);
 					viewHolder.image = (ImageView) view
 						.findViewById(R.id.message_image);
 					viewHolder.messageBody = (TextView) view
@@ -701,6 +713,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 		protected TextView status_message;
 		protected TextView encryption;
 		public Button load_more_messages;
+		public ImageView edit_indicator;
 	}
 
 	class BitmapWorkerTask extends AsyncTask<Message, Void, Bitmap> {

src/main/res/layout/message_received.xml 🔗

@@ -91,7 +91,17 @@
                     android:layout_marginRight="4sp"
                     android:alpha="0.70"
                     android:gravity="center_vertical"
-                    android:src="@drawable/ic_secure_indicator_white" />
+                    android:src="@drawable/ic_lock_white_18dp" />
+
+                <ImageView
+                    android:id="@+id/edit_indicator"
+                    android:layout_width="?attr/TextSizeInfo"
+                    android:layout_height="?attr/TextSizeInfo"
+                    android:layout_gravity="center_vertical"
+                    android:layout_marginRight="4sp"
+                    android:alpha="0.70"
+                    android:gravity="center_vertical"
+                    android:src="@drawable/ic_mode_edit_white_18dp" />
 
                 <TextView
                     android:id="@+id/message_time"

src/main/res/layout/message_sent.xml 🔗

@@ -91,7 +91,17 @@
                     android:layout_marginLeft="4sp"
                     android:alpha="0.54"
                     android:gravity="center_vertical"
-                    android:src="@drawable/ic_secure_indicator" />
+                    android:src="@drawable/ic_lock_black_18dp" />
+
+                <ImageView
+                    android:id="@+id/edit_indicator"
+                    android:layout_width="?attr/TextSizeInfo"
+                    android:layout_height="?attr/TextSizeInfo"
+                    android:layout_gravity="center_vertical"
+                    android:layout_marginLeft="4sp"
+                    android:alpha="0.54"
+                    android:gravity="center_vertical"
+                    android:src="@drawable/ic_mode_edit_black_18dp" />
 
                 <ImageView
                     android:id="@+id/indicator_received"

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

@@ -5,6 +5,10 @@
         android:id="@+id/copy_text"
         android:title="@string/copy_text"
         android:visible="false"/>
+    <item
+        android:id="@+id/correct_message"
+        android:title="@string/correct_message"
+        android:visible="false"/>
     <item
         android:id="@+id/share_with"
         android:title="@string/share_with"

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

@@ -593,4 +593,6 @@
 	<string name="selection_too_large">The selected area is too large</string>
 	<string name="no_accounts">(No activated accounts)</string>
 	<string name="this_field_is_required">This field is required</string>
+	<string name="correct_message">Correct message</string>
+	<string name="send_corrected_message">Send corrected message</string>
 </resources>