show call log messages in conversation stream

Daniel Gultsch created

Change summary

src/main/java/eu/siacs/conversations/entities/Message.java                |   26 
src/main/java/eu/siacs/conversations/entities/RtpSessionStatus.java       |   59 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java       | 1858 
src/main/java/eu/siacs/conversations/utils/UIHelper.java                  |    2 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java |   62 
src/main/res/drawable-hdpi/ic_call_made_black_18dp.png                    |    0 
src/main/res/drawable-hdpi/ic_call_made_white_18dp.png                    |    0 
src/main/res/drawable-hdpi/ic_call_missed_black_18dp.png                  |    0 
src/main/res/drawable-hdpi/ic_call_missed_outgoing_black_18dp.png         |    0 
src/main/res/drawable-hdpi/ic_call_missed_outgoing_white_18dp.png         |    0 
src/main/res/drawable-hdpi/ic_call_missed_white_18dp.png                  |    0 
src/main/res/drawable-hdpi/ic_call_received_black_18dp.png                |    0 
src/main/res/drawable-hdpi/ic_call_received_white_18dp.png                |    0 
src/main/res/drawable-mdpi/ic_call_made_black_18dp.png                    |    0 
src/main/res/drawable-mdpi/ic_call_made_white_18dp.png                    |    0 
src/main/res/drawable-mdpi/ic_call_missed_black_18dp.png                  |    0 
src/main/res/drawable-mdpi/ic_call_missed_outgoing_black_18dp.png         |    0 
src/main/res/drawable-mdpi/ic_call_missed_outgoing_white_18dp.png         |    0 
src/main/res/drawable-mdpi/ic_call_missed_white_18dp.png                  |    0 
src/main/res/drawable-mdpi/ic_call_received_black_18dp.png                |    0 
src/main/res/drawable-mdpi/ic_call_received_white_18dp.png                |    0 
src/main/res/drawable-xhdpi/ic_call_made_black_18dp.png                   |    0 
src/main/res/drawable-xhdpi/ic_call_made_white_18dp.png                   |    0 
src/main/res/drawable-xhdpi/ic_call_missed_black_18dp.png                 |    0 
src/main/res/drawable-xhdpi/ic_call_missed_outgoing_black_18dp.png        |    0 
src/main/res/drawable-xhdpi/ic_call_missed_outgoing_white_18dp.png        |    0 
src/main/res/drawable-xhdpi/ic_call_missed_white_18dp.png                 |    0 
src/main/res/drawable-xhdpi/ic_call_received_black_18dp.png               |    0 
src/main/res/drawable-xhdpi/ic_call_received_white_18dp.png               |    0 
src/main/res/drawable-xxhdpi/ic_call_made_black_18dp.png                  |    0 
src/main/res/drawable-xxhdpi/ic_call_made_white_18dp.png                  |    0 
src/main/res/drawable-xxhdpi/ic_call_missed_black_18dp.png                |    0 
src/main/res/drawable-xxhdpi/ic_call_missed_outgoing_black_18dp.png       |    0 
src/main/res/drawable-xxhdpi/ic_call_missed_outgoing_white_18dp.png       |    0 
src/main/res/drawable-xxhdpi/ic_call_missed_white_18dp.png                |    0 
src/main/res/drawable-xxhdpi/ic_call_received_black_18dp.png              |    0 
src/main/res/drawable-xxhdpi/ic_call_received_white_18dp.png              |    0 
src/main/res/drawable-xxxhdpi/ic_call_made_black_18dp.png                 |    0 
src/main/res/drawable-xxxhdpi/ic_call_made_white_18dp.png                 |    0 
src/main/res/drawable-xxxhdpi/ic_call_missed_black_18dp.png               |    0 
src/main/res/drawable-xxxhdpi/ic_call_missed_outgoing_black_18dp.png      |    0 
src/main/res/drawable-xxxhdpi/ic_call_missed_outgoing_white_18dp.png      |    0 
src/main/res/drawable-xxxhdpi/ic_call_missed_white_18dp.png               |    0 
src/main/res/drawable-xxxhdpi/ic_call_received_black_18dp.png             |    0 
src/main/res/drawable-xxxhdpi/ic_call_received_white_18dp.png             |    0 
src/main/res/layout/message_date_bubble.xml                               |   15 
src/main/res/layout/message_rtp_session.xml                               |   38 
src/main/res/values/strings.xml                                           |    4 
48 files changed, 1,143 insertions(+), 921 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/entities/Message.java ๐Ÿ”—

@@ -57,6 +57,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
 	public static final int TYPE_STATUS = 3;
 	public static final int TYPE_PRIVATE = 4;
 	public static final int TYPE_PRIVATE_FILE = 5;
+	public static final int TYPE_RTP_SESSION = 6;
 
 	public static final String CONVERSATION = "conversationUuid";
 	public static final String COUNTERPART = "counterpart";
@@ -151,6 +152,31 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
 				null);
 	}
 
+	public Message(Conversation conversation, int status, int type, final String remoteMsgId) {
+		this(conversation, java.util.UUID.randomUUID().toString(),
+				conversation.getUuid(),
+				conversation.getJid() == null ? null : conversation.getJid().asBareJid(),
+				null,
+				null,
+				System.currentTimeMillis(),
+				Message.ENCRYPTION_NONE,
+				status,
+				type,
+				false,
+				remoteMsgId,
+				null,
+				null,
+				null,
+				true,
+				null,
+				false,
+				null,
+				null,
+				false,
+				false,
+				null);
+	}
+
 	protected Message(final Conversational conversation, final String uuid, final String conversationUUid, final Jid counterpart,
 	                final Jid trueCounterpart, final String body, final long timeSent,
 	                final int encryption, final int status, final int type, final boolean carbon,

src/main/java/eu/siacs/conversations/entities/RtpSessionStatus.java ๐Ÿ”—

@@ -0,0 +1,59 @@
+package eu.siacs.conversations.entities;
+
+import android.support.annotation.DrawableRes;
+
+import com.google.common.base.Strings;
+
+import eu.siacs.conversations.R;
+
+public class RtpSessionStatus {
+
+    public final boolean successful;
+    public final long duration;
+
+
+    public RtpSessionStatus(boolean successful, long duration) {
+        this.successful = successful;
+        this.duration = duration;
+    }
+
+    @Override
+    public String toString() {
+        return successful + ":" + duration;
+    }
+
+    public static RtpSessionStatus of(final String body) {
+        final String[] parts = Strings.nullToEmpty(body).split(":", 2);
+        long duration = 0;
+        if (parts.length == 2) {
+            try {
+                duration = Long.parseLong(parts[1]);
+            } catch (NumberFormatException e) {
+                //do nothing
+            }
+        }
+        boolean made;
+        try {
+            made = Boolean.parseBoolean(parts[0]);
+        } catch (Exception e) {
+            made = false;
+        }
+        return new RtpSessionStatus(made, duration);
+    }
+
+    public static @DrawableRes int getDrawable(final boolean received, final boolean successful, final boolean darkTheme) {
+        if (received) {
+            if (successful) {
+                return darkTheme ? R.drawable.ic_call_received_white_18dp : R.drawable.ic_call_received_black_18dp;
+            } else {
+                return darkTheme ? R.drawable.ic_call_missed_white_18dp : R.drawable.ic_call_missed_black_18dp;
+            }
+        } else {
+            if (successful) {
+                return darkTheme ? R.drawable.ic_call_made_white_18dp : R.drawable.ic_call_made_black_18dp;
+            } else {
+                return darkTheme ? R.drawable.ic_call_missed_outgoing_white_18dp : R.drawable.ic_call_missed_outgoing_black_18dp;
+            }
+        }
+    }
+}

src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java ๐Ÿ”—

@@ -49,6 +49,7 @@ import eu.siacs.conversations.entities.Conversational;
 import eu.siacs.conversations.entities.DownloadableFile;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.Message.FileParams;
+import eu.siacs.conversations.entities.RtpSessionStatus;
 import eu.siacs.conversations.entities.Transferable;
 import eu.siacs.conversations.http.P1S3UrlStreamHandler;
 import eu.siacs.conversations.persistance.FileBackend;
@@ -72,926 +73,959 @@ import eu.siacs.conversations.utils.Emoticons;
 import eu.siacs.conversations.utils.GeoHelper;
 import eu.siacs.conversations.utils.MessageUtils;
 import eu.siacs.conversations.utils.StylingHelper;
+import eu.siacs.conversations.utils.TimeframeUtils;
 import eu.siacs.conversations.utils.UIHelper;
 import eu.siacs.conversations.xmpp.mam.MamReference;
 import rocks.xmpp.addr.Jid;
 
 public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextView.CopyHandler {
 
-	public static final String DATE_SEPARATOR_BODY = "DATE_SEPARATOR";
-	private static final int SENT = 0;
-	private static final int RECEIVED = 1;
-	private static final int STATUS = 2;
-	private static final int DATE_SEPARATOR = 3;
-	private final XmppActivity activity;
-	private final ListSelectionManager listSelectionManager = new ListSelectionManager();
-	private final AudioPlayer audioPlayer;
-	private List<String> highlightedTerm = null;
-	private DisplayMetrics metrics;
-	private OnContactPictureClicked mOnContactPictureClickedListener;
-	private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
-	private boolean mUseGreenBackground = false;
-	private OnQuoteListener onQuoteListener;
-	public MessageAdapter(XmppActivity activity, List<Message> messages) {
-		super(activity, 0, messages);
-		this.audioPlayer = new AudioPlayer(this);
-		this.activity = activity;
-		metrics = getContext().getResources().getDisplayMetrics();
-		updatePreferences();
-	}
-
-
-
-	private static void resetClickListener(View... views) {
-		for (View view : views) {
-			view.setOnClickListener(null);
-		}
-	}
-
-	public void flagScreenOn() {
-		activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
-	}
-
-	public void flagScreenOff() {
-		activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
-	}
-
-	public void setOnContactPictureClicked(OnContactPictureClicked listener) {
-		this.mOnContactPictureClickedListener = listener;
-	}
-
-	public Activity getActivity() {
-		return activity;
-	}
-
-	public void setOnContactPictureLongClicked(
-			OnContactPictureLongClicked listener) {
-		this.mOnContactPictureLongClickedListener = listener;
-	}
-
-	public void setOnQuoteListener(OnQuoteListener listener) {
-		this.onQuoteListener = listener;
-	}
-
-	@Override
-	public int getViewTypeCount() {
-		return 4;
-	}
-
-	private int getItemViewType(Message message) {
-		if (message.getType() == Message.TYPE_STATUS) {
-			if (DATE_SEPARATOR_BODY.equals(message.getBody())) {
-				return DATE_SEPARATOR;
-			} else {
-				return STATUS;
-			}
-		} else if (message.getStatus() <= Message.STATUS_RECEIVED) {
-			return RECEIVED;
-		}
-
-		return SENT;
-	}
-
-	@Override
-	public int getItemViewType(int position) {
-		return this.getItemViewType(getItem(position));
-	}
-
-	private int getMessageTextColor(boolean onDark, boolean primary) {
-		if (onDark) {
-			return ContextCompat.getColor(activity, primary ? R.color.white : R.color.white70);
-		} else {
-			return ContextCompat.getColor(activity, primary ? R.color.black87 : R.color.black54);
-		}
-	}
-
-	private void displayStatus(ViewHolder viewHolder, Message message, int type, boolean darkBackground) {
-		String filesize = null;
-		String info = null;
-		boolean error = false;
-		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);
-			}
-		}
-		final Transferable transferable = message.getTransferable();
-		boolean multiReceived = message.getConversation().getMode() == Conversation.MODE_MULTI
-				&& message.getMergedStatus() <= Message.STATUS_RECEIVED;
-		if (message.isFileOrImage() || transferable != null || MessageUtils.unInitiatedButKnownSize(message)) {
-			FileParams params = message.getFileParams();
-			filesize = params.size > 0 ? UIHelper.filesizeToString(params.size) : null;
-			if (transferable != null && (transferable.getStatus() == Transferable.STATUS_FAILED || transferable.getStatus() == Transferable.STATUS_CANCELLED)) {
-				error = true;
-			}
-		}
-		switch (message.getMergedStatus()) {
-			case Message.STATUS_WAITING:
-				info = getContext().getString(R.string.waiting);
-				break;
-			case Message.STATUS_UNSEND:
-				if (transferable != null) {
-					info = getContext().getString(R.string.sending_file, transferable.getProgress());
-				} else {
-					info = getContext().getString(R.string.sending);
-				}
-				break;
-			case Message.STATUS_OFFERED:
-				info = getContext().getString(R.string.offering);
-				break;
-			case Message.STATUS_SEND_RECEIVED:
-			case Message.STATUS_SEND_DISPLAYED:
-				viewHolder.indicatorReceived.setImageResource(darkBackground ? R.drawable.ic_done_white_18dp : R.drawable.ic_done_black_18dp);
-				viewHolder.indicatorReceived.setAlpha(darkBackground ? 0.7f : 0.57f);
-				viewHolder.indicatorReceived.setVisibility(View.VISIBLE);
-				break;
-			case Message.STATUS_SEND_FAILED:
-				final String errorMessage = message.getErrorMessage();
-				if (Message.ERROR_MESSAGE_CANCELLED.equals(errorMessage)) {
-					info = getContext().getString(R.string.cancelled);
-				} else if (errorMessage != null) {
-					final String[] errorParts = errorMessage.split("\\u001f", 2);
-					if (errorParts.length == 2) {
-						switch (errorParts[0]) {
-							case "file-too-large":
-								info = getContext().getString(R.string.file_too_large);
-								break;
-							default:
-								info = getContext().getString(R.string.send_failed);
-								break;
-						}
-					} else {
-						info = getContext().getString(R.string.send_failed);
-					}
-				} else {
-					info = getContext().getString(R.string.send_failed);
-				}
-				error = true;
-				break;
-			default:
-				if (multiReceived) {
-					info = UIHelper.getMessageDisplayName(message);
-				}
-				break;
-		}
-		if (error && type == SENT) {
-			if (darkBackground) {
-				viewHolder.time.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Caption_Warning_OnDark);
-			} else {
-				viewHolder.time.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Caption_Warning);
-			}
-		} else {
-			if (darkBackground) {
-				viewHolder.time.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Caption_OnDark);
-			} else {
-				viewHolder.time.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Caption);
-			}
-			viewHolder.time.setTextColor(this.getMessageTextColor(darkBackground, false));
-		}
-		if (message.getEncryption() == Message.ENCRYPTION_NONE) {
-			viewHolder.indicator.setVisibility(View.GONE);
-		} else {
-			boolean verified = false;
-			if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
-				final FingerprintStatus status = message.getConversation()
-						.getAccount().getAxolotlService().getFingerprintTrust(
-								message.getFingerprint());
-				if (status != null && status.isVerified()) {
-					verified = true;
-				}
-			}
-			if (verified) {
-				viewHolder.indicator.setImageResource(darkBackground ? R.drawable.ic_verified_user_white_18dp : R.drawable.ic_verified_user_black_18dp);
-			} else {
-				viewHolder.indicator.setImageResource(darkBackground ? R.drawable.ic_lock_white_18dp : R.drawable.ic_lock_black_18dp);
-			}
-			if (darkBackground) {
-				viewHolder.indicator.setAlpha(0.7f);
-			} else {
-				viewHolder.indicator.setAlpha(0.57f);
-			}
-			viewHolder.indicator.setVisibility(View.VISIBLE);
-		}
-
-		final String formattedTime = UIHelper.readableTimeDifferenceFull(getContext(), message.getMergedTimeSent());
-		final String bodyLanguage = message.getBodyLanguage();
-		final String bodyLanguageInfo = bodyLanguage == null ? "" : String.format(" \u00B7 %s", bodyLanguage.toUpperCase(Locale.US));
-		if (message.getStatus() <= Message.STATUS_RECEIVED) { ;
-			if ((filesize != null) && (info != null)) {
-				viewHolder.time.setText(formattedTime + " \u00B7 " + filesize + " \u00B7 " + info + bodyLanguageInfo);
-			} else if ((filesize == null) && (info != null)) {
-				viewHolder.time.setText(formattedTime + " \u00B7 " + info + bodyLanguageInfo);
-			} else if ((filesize != null) && (info == null)) {
-				viewHolder.time.setText(formattedTime + " \u00B7 " + filesize + bodyLanguageInfo);
-			} else {
-				viewHolder.time.setText(formattedTime+bodyLanguageInfo);
-			}
-		} else {
-			if ((filesize != null) && (info != null)) {
-				viewHolder.time.setText(filesize + " \u00B7 " + info + bodyLanguageInfo);
-			} else if ((filesize == null) && (info != null)) {
-				if (error) {
-					viewHolder.time.setText(info + " \u00B7 " + formattedTime + bodyLanguageInfo);
-				} else {
-					viewHolder.time.setText(info);
-				}
-			} else if ((filesize != null) && (info == null)) {
-				viewHolder.time.setText(filesize + " \u00B7 " + formattedTime + bodyLanguageInfo);
-			} else {
-				viewHolder.time.setText(formattedTime+bodyLanguageInfo);
-			}
-		}
-	}
-
-	private void displayInfoMessage(ViewHolder viewHolder, CharSequence text, boolean darkBackground) {
-		viewHolder.download_button.setVisibility(View.GONE);
-		viewHolder.audioPlayer.setVisibility(View.GONE);
-		viewHolder.image.setVisibility(View.GONE);
-		viewHolder.messageBody.setVisibility(View.VISIBLE);
-		viewHolder.messageBody.setText(text);
-		if (darkBackground) {
-			viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_Secondary_OnDark);
-		} else {
-			viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_Secondary);
-		}
-		viewHolder.messageBody.setTextIsSelectable(false);
-	}
-
-	private void displayEmojiMessage(final ViewHolder viewHolder, final String body, final boolean darkBackground) {
-		viewHolder.download_button.setVisibility(View.GONE);
-		viewHolder.audioPlayer.setVisibility(View.GONE);
-		viewHolder.image.setVisibility(View.GONE);
-		viewHolder.messageBody.setVisibility(View.VISIBLE);
-		if (darkBackground) {
-			viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_Emoji_OnDark);
-		} else {
-			viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_Emoji);
-		}
-		Spannable span = new SpannableString(body);
-		float size = Emoticons.isEmoji(body) ? 3.0f : 2.0f;
-		span.setSpan(new RelativeSizeSpan(size), 0, body.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-		viewHolder.messageBody.setText(EmojiWrapper.transform(span));
-	}
-
-	private void applyQuoteSpan(SpannableStringBuilder body, int start, int end, boolean darkBackground) {
-		if (start > 1 && !"\n\n".equals(body.subSequence(start - 2, start).toString())) {
-			body.insert(start++, "\n");
-			body.setSpan(new DividerSpan(false), start - 2, start, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-			end++;
-		}
-		if (end < body.length() - 1 && !"\n\n".equals(body.subSequence(end, end + 2).toString())) {
-			body.insert(end, "\n");
-			body.setSpan(new DividerSpan(false), end, end + 2, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-		}
-		int color = darkBackground ? this.getMessageTextColor(darkBackground, false)
-				: ContextCompat.getColor(activity, R.color.green700_desaturated);
-		DisplayMetrics metrics = getContext().getResources().getDisplayMetrics();
-		body.setSpan(new QuoteSpan(color, metrics), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-	}
-
-	/**
-	 * Applies QuoteSpan to group of lines which starts with > or ยป characters.
-	 * Appends likebreaks and applies DividerSpan to them to show a padding between quote and text.
-	 */
-	private boolean handleTextQuotes(SpannableStringBuilder body, boolean darkBackground) {
-		boolean startsWithQuote = false;
-		char previous = '\n';
-		int lineStart = -1;
-		int lineTextStart = -1;
-		int quoteStart = -1;
-		for (int i = 0; i <= body.length(); i++) {
-			char current = body.length() > i ? body.charAt(i) : '\n';
-			if (lineStart == -1) {
-				if (previous == '\n') {
-					if ((current == '>' && UIHelper.isPositionFollowedByQuoteableCharacter(body, i))
-							|| current == '\u00bb' && !UIHelper.isPositionFollowedByQuote(body, i)) {
-						// Line start with quote
-						lineStart = i;
-						if (quoteStart == -1) quoteStart = i;
-						if (i == 0) startsWithQuote = true;
-					} else if (quoteStart >= 0) {
-						// Line start without quote, apply spans there
-						applyQuoteSpan(body, quoteStart, i - 1, darkBackground);
-						quoteStart = -1;
-					}
-				}
-			} else {
-				// Remove extra spaces between > and first character in the line
-				// > character will be removed too
-				if (current != ' ' && lineTextStart == -1) {
-					lineTextStart = i;
-				}
-				if (current == '\n') {
-					body.delete(lineStart, lineTextStart);
-					i -= lineTextStart - lineStart;
-					if (i == lineStart) {
-						// Avoid empty lines because span over empty line can be hidden
-						body.insert(i++, " ");
-					}
-					lineStart = -1;
-					lineTextStart = -1;
-				}
-			}
-			previous = current;
-		}
-		if (quoteStart >= 0) {
-			// Apply spans to finishing open quote
-			applyQuoteSpan(body, quoteStart, body.length(), darkBackground);
-		}
-		return startsWithQuote;
-	}
-
-	private void displayTextMessage(final ViewHolder viewHolder, final Message message, boolean darkBackground, int type) {
-		viewHolder.download_button.setVisibility(View.GONE);
-		viewHolder.image.setVisibility(View.GONE);
-		viewHolder.audioPlayer.setVisibility(View.GONE);
-		viewHolder.messageBody.setVisibility(View.VISIBLE);
-
-		if (darkBackground) {
-			viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_OnDark);
-		} else {
-			viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1);
-		}
-		viewHolder.messageBody.setHighlightColor(ContextCompat.getColor(activity, darkBackground
-				? (type == SENT || !mUseGreenBackground ? R.color.black26 : R.color.grey800) : R.color.grey500));
-		viewHolder.messageBody.setTypeface(null, Typeface.NORMAL);
-
-		if (message.getBody() != null) {
-			final String nick = UIHelper.getMessageDisplayName(message);
-			SpannableStringBuilder body = message.getMergedBody();
-			boolean hasMeCommand = message.hasMeCommand();
-			if (hasMeCommand) {
-				body = body.replace(0, Message.ME_COMMAND.length(), nick + " ");
-			}
-			if (body.length() > Config.MAX_DISPLAY_MESSAGE_CHARS) {
-				body = new SpannableStringBuilder(body, 0, Config.MAX_DISPLAY_MESSAGE_CHARS);
-				body.append("\u2026");
-			}
-			Message.MergeSeparator[] mergeSeparators = body.getSpans(0, body.length(), Message.MergeSeparator.class);
-			for (Message.MergeSeparator mergeSeparator : mergeSeparators) {
-				int start = body.getSpanStart(mergeSeparator);
-				int end = body.getSpanEnd(mergeSeparator);
-				body.setSpan(new DividerSpan(true), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-			}
-			boolean startsWithQuote = handleTextQuotes(body, darkBackground);
-			if (!message.isPrivateMessage()) {
-				if (hasMeCommand) {
-					body.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), 0, nick.length(),
-							Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-				}
-			} else {
-				String privateMarker;
-				if (message.getStatus() <= Message.STATUS_RECEIVED) {
-					privateMarker = activity.getString(R.string.private_message);
-				} else {
-					Jid cp = message.getCounterpart();
-					privateMarker = activity.getString(R.string.private_message_to, Strings.nullToEmpty(cp == null ? null : cp.getResource()));
-				}
-				body.insert(0, privateMarker);
-				int privateMarkerIndex = privateMarker.length();
-				if (startsWithQuote) {
-					body.insert(privateMarkerIndex, "\n\n");
-					body.setSpan(new DividerSpan(false), privateMarkerIndex, privateMarkerIndex + 2,
-							Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-				} else {
-					body.insert(privateMarkerIndex, " ");
-				}
-				body.setSpan(new ForegroundColorSpan(getMessageTextColor(darkBackground, false)), 0, privateMarkerIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-				body.setSpan(new StyleSpan(Typeface.BOLD), 0, privateMarkerIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-				if (hasMeCommand) {
-					body.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), privateMarkerIndex + 1,
-							privateMarkerIndex + 1 + nick.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-				}
-			}
-			if (message.getConversation().getMode() == Conversation.MODE_MULTI && message.getStatus() == Message.STATUS_RECEIVED) {
-				if (message.getConversation() instanceof Conversation) {
-					final Conversation conversation = (Conversation) message.getConversation();
-					Pattern pattern = NotificationService.generateNickHighlightPattern(conversation.getMucOptions().getActualNick());
-					Matcher matcher = pattern.matcher(body);
-					while (matcher.find()) {
-						body.setSpan(new StyleSpan(Typeface.BOLD), matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-					}
-				}
-			}
-			Matcher matcher = Emoticons.getEmojiPattern(body).matcher(body);
-			while (matcher.find()) {
-				if (matcher.start() < matcher.end()) {
-					body.setSpan(new RelativeSizeSpan(1.2f), matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-				}
-			}
-
-			StylingHelper.format(body, viewHolder.messageBody.getCurrentTextColor());
-			if (highlightedTerm != null) {
-				StylingHelper.highlight(activity, body, highlightedTerm, StylingHelper.isDarkText(viewHolder.messageBody));
-			}
-			MyLinkify.addLinks(body,true);
-			viewHolder.messageBody.setAutoLinkMask(0);
-			viewHolder.messageBody.setText(EmojiWrapper.transform(body));
-			viewHolder.messageBody.setTextIsSelectable(true);
-			viewHolder.messageBody.setMovementMethod(ClickableMovementMethod.getInstance());
-			listSelectionManager.onUpdate(viewHolder.messageBody, message);
-		} else {
-			viewHolder.messageBody.setText("");
-			viewHolder.messageBody.setTextIsSelectable(false);
-		}
-	}
-
-	private void displayDownloadableMessage(ViewHolder viewHolder, final Message message, String text, final boolean darkBackground) {
-		toggleWhisperInfo(viewHolder, message, darkBackground);
-		viewHolder.image.setVisibility(View.GONE);
-		viewHolder.audioPlayer.setVisibility(View.GONE);
-		viewHolder.download_button.setVisibility(View.VISIBLE);
-		viewHolder.download_button.setText(text);
-		viewHolder.download_button.setOnClickListener(v -> ConversationFragment.downloadFile(activity, message));
-	}
-
-	private void displayOpenableMessage(ViewHolder viewHolder, final Message message, final boolean darkBackground) {
-		toggleWhisperInfo(viewHolder, message, darkBackground);
-		viewHolder.image.setVisibility(View.GONE);
-		viewHolder.audioPlayer.setVisibility(View.GONE);
-		viewHolder.download_button.setVisibility(View.VISIBLE);
-		viewHolder.download_button.setText(activity.getString(R.string.open_x_file, UIHelper.getFileDescriptionString(activity, message)));
-		viewHolder.download_button.setOnClickListener(v -> openDownloadable(message));
-	}
-
-	private void displayLocationMessage(ViewHolder viewHolder, final Message message, final boolean darkBackground) {
-		toggleWhisperInfo(viewHolder, message, darkBackground);
-		viewHolder.image.setVisibility(View.GONE);
-		viewHolder.audioPlayer.setVisibility(View.GONE);
-		viewHolder.download_button.setVisibility(View.VISIBLE);
-		viewHolder.download_button.setText(R.string.show_location);
-		viewHolder.download_button.setOnClickListener(v -> showLocation(message));
-	}
-
-	private void displayAudioMessage(ViewHolder viewHolder, Message message, boolean darkBackground) {
-		toggleWhisperInfo(viewHolder, message, darkBackground);
-		viewHolder.image.setVisibility(View.GONE);
-		viewHolder.download_button.setVisibility(View.GONE);
-		final RelativeLayout audioPlayer = viewHolder.audioPlayer;
-		audioPlayer.setVisibility(View.VISIBLE);
-		AudioPlayer.ViewHolder.get(audioPlayer).setDarkBackground(darkBackground);
-		this.audioPlayer.init(audioPlayer, message);
-	}
-
-	private void displayMediaPreviewMessage(ViewHolder viewHolder, final Message message, final boolean darkBackground) {
-		toggleWhisperInfo(viewHolder, message, darkBackground);
-		viewHolder.download_button.setVisibility(View.GONE);
-		viewHolder.audioPlayer.setVisibility(View.GONE);
-		viewHolder.image.setVisibility(View.VISIBLE);
-		final FileParams params = message.getFileParams();
-		final double target = metrics.density * 288;
-		final int scaledW;
-		final int scaledH;
-		if (Math.max(params.height, params.width) * metrics.density <= target) {
-			scaledW = (int) (params.width * metrics.density);
-			scaledH = (int) (params.height * metrics.density);
-		} else if (Math.max(params.height, params.width) <= target) {
-			scaledW = params.width;
-			scaledH = params.height;
-		} else if (params.width <= params.height) {
-			scaledW = (int) (params.width / ((double) params.height / target));
-			scaledH = (int) target;
-		} else {
-			scaledW = (int) target;
-			scaledH = (int) (params.height / ((double) params.width / target));
-		}
-		LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(scaledW, scaledH);
-		layoutParams.setMargins(0, (int) (metrics.density * 4), 0, (int) (metrics.density * 4));
-		viewHolder.image.setLayoutParams(layoutParams);
-		activity.loadBitmap(message, viewHolder.image);
-		viewHolder.image.setOnClickListener(v -> openDownloadable(message));
-	}
-
-	private void toggleWhisperInfo(ViewHolder viewHolder, final Message message, final boolean darkBackground) {
-		if (message.isPrivateMessage()) {
-			final String privateMarker;
-			if (message.getStatus() <= Message.STATUS_RECEIVED) {
-				privateMarker = activity.getString(R.string.private_message);
-			} else {
-				Jid cp = message.getCounterpart();
-				privateMarker = activity.getString(R.string.private_message_to, Strings.nullToEmpty(cp == null ? null : cp.getResource()));
-			}
-			final SpannableString body = new SpannableString(privateMarker);
-			body.setSpan(new ForegroundColorSpan(getMessageTextColor(darkBackground, false)), 0, privateMarker.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-			body.setSpan(new StyleSpan(Typeface.BOLD), 0, privateMarker.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-			viewHolder.messageBody.setText(body);
-			viewHolder.messageBody.setVisibility(View.VISIBLE);
-		} else {
-			viewHolder.messageBody.setVisibility(View.GONE);
-		}
-	}
-
-	private void loadMoreMessages(Conversation conversation) {
-		conversation.setLastClearHistory(0, null);
-		activity.xmppConnectionService.updateConversation(conversation);
-		conversation.setHasMessagesLeftOnServer(true);
-		conversation.setFirstMamReference(null);
-		long timestamp = conversation.getLastMessageTransmitted().getTimestamp();
-		if (timestamp == 0) {
-			timestamp = System.currentTimeMillis();
-		}
-		conversation.messagesLoaded.set(true);
-		MessageArchiveService.Query query = activity.xmppConnectionService.getMessageArchiveService().query(conversation, new MamReference(0), timestamp, false);
-		if (query != null) {
-			Toast.makeText(activity, R.string.fetching_history_from_server, Toast.LENGTH_LONG).show();
-		} else {
-			Toast.makeText(activity, R.string.not_fetching_history_retention_period, Toast.LENGTH_SHORT).show();
-		}
-	}
-
-	@Override
-	public View getView(int position, View view, ViewGroup parent) {
-		final Message message = getItem(position);
-		final boolean omemoEncryption = message.getEncryption() == Message.ENCRYPTION_AXOLOTL;
-		final boolean isInValidSession = message.isValidInSession() && (!omemoEncryption || message.isTrusted());
-		final Conversational conversation = message.getConversation();
-		final Account account = conversation.getAccount();
-		final int type = getItemViewType(position);
-		ViewHolder viewHolder;
-		if (view == null) {
-			viewHolder = new ViewHolder();
-			switch (type) {
-				case DATE_SEPARATOR:
-					view = activity.getLayoutInflater().inflate(R.layout.message_date_bubble, parent, false);
+    public static final String DATE_SEPARATOR_BODY = "DATE_SEPARATOR";
+    private static final int SENT = 0;
+    private static final int RECEIVED = 1;
+    private static final int STATUS = 2;
+    private static final int DATE_SEPARATOR = 3;
+    private static final int RTP_SESSION = 4;
+    private final XmppActivity activity;
+    private final ListSelectionManager listSelectionManager = new ListSelectionManager();
+    private final AudioPlayer audioPlayer;
+    private List<String> highlightedTerm = null;
+    private DisplayMetrics metrics;
+    private OnContactPictureClicked mOnContactPictureClickedListener;
+    private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
+    private boolean mUseGreenBackground = false;
+    private OnQuoteListener onQuoteListener;
+
+    public MessageAdapter(XmppActivity activity, List<Message> messages) {
+        super(activity, 0, messages);
+        this.audioPlayer = new AudioPlayer(this);
+        this.activity = activity;
+        metrics = getContext().getResources().getDisplayMetrics();
+        updatePreferences();
+    }
+
+
+    private static void resetClickListener(View... views) {
+        for (View view : views) {
+            view.setOnClickListener(null);
+        }
+    }
+
+    public void flagScreenOn() {
+        activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+    }
+
+    public void flagScreenOff() {
+        activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+    }
+
+    public void setOnContactPictureClicked(OnContactPictureClicked listener) {
+        this.mOnContactPictureClickedListener = listener;
+    }
+
+    public Activity getActivity() {
+        return activity;
+    }
+
+    public void setOnContactPictureLongClicked(
+            OnContactPictureLongClicked listener) {
+        this.mOnContactPictureLongClickedListener = listener;
+    }
+
+    public void setOnQuoteListener(OnQuoteListener listener) {
+        this.onQuoteListener = listener;
+    }
+
+    @Override
+    public int getViewTypeCount() {
+        return 5;
+    }
+
+    private int getItemViewType(Message message) {
+        if (message.getType() == Message.TYPE_STATUS) {
+            if (DATE_SEPARATOR_BODY.equals(message.getBody())) {
+                return DATE_SEPARATOR;
+            } else {
+                return STATUS;
+            }
+        } else if (message.getType() == Message.TYPE_RTP_SESSION) {
+            return RTP_SESSION;
+        } else if (message.getStatus() <= Message.STATUS_RECEIVED) {
+            return RECEIVED;
+        } else {
+            return SENT;
+        }
+    }
+
+    @Override
+    public int getItemViewType(int position) {
+        return this.getItemViewType(getItem(position));
+    }
+
+    private int getMessageTextColor(boolean onDark, boolean primary) {
+        if (onDark) {
+            return ContextCompat.getColor(activity, primary ? R.color.white : R.color.white70);
+        } else {
+            return ContextCompat.getColor(activity, primary ? R.color.black87 : R.color.black54);
+        }
+    }
+
+    private void displayStatus(ViewHolder viewHolder, Message message, int type, boolean darkBackground) {
+        String filesize = null;
+        String info = null;
+        boolean error = false;
+        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);
+            }
+        }
+        final Transferable transferable = message.getTransferable();
+        boolean multiReceived = message.getConversation().getMode() == Conversation.MODE_MULTI
+                && message.getMergedStatus() <= Message.STATUS_RECEIVED;
+        if (message.isFileOrImage() || transferable != null || MessageUtils.unInitiatedButKnownSize(message)) {
+            FileParams params = message.getFileParams();
+            filesize = params.size > 0 ? UIHelper.filesizeToString(params.size) : null;
+            if (transferable != null && (transferable.getStatus() == Transferable.STATUS_FAILED || transferable.getStatus() == Transferable.STATUS_CANCELLED)) {
+                error = true;
+            }
+        }
+        switch (message.getMergedStatus()) {
+            case Message.STATUS_WAITING:
+                info = getContext().getString(R.string.waiting);
+                break;
+            case Message.STATUS_UNSEND:
+                if (transferable != null) {
+                    info = getContext().getString(R.string.sending_file, transferable.getProgress());
+                } else {
+                    info = getContext().getString(R.string.sending);
+                }
+                break;
+            case Message.STATUS_OFFERED:
+                info = getContext().getString(R.string.offering);
+                break;
+            case Message.STATUS_SEND_RECEIVED:
+            case Message.STATUS_SEND_DISPLAYED:
+                viewHolder.indicatorReceived.setImageResource(darkBackground ? R.drawable.ic_done_white_18dp : R.drawable.ic_done_black_18dp);
+                viewHolder.indicatorReceived.setAlpha(darkBackground ? 0.7f : 0.57f);
+                viewHolder.indicatorReceived.setVisibility(View.VISIBLE);
+                break;
+            case Message.STATUS_SEND_FAILED:
+                final String errorMessage = message.getErrorMessage();
+                if (Message.ERROR_MESSAGE_CANCELLED.equals(errorMessage)) {
+                    info = getContext().getString(R.string.cancelled);
+                } else if (errorMessage != null) {
+                    final String[] errorParts = errorMessage.split("\\u001f", 2);
+                    if (errorParts.length == 2) {
+                        switch (errorParts[0]) {
+                            case "file-too-large":
+                                info = getContext().getString(R.string.file_too_large);
+                                break;
+                            default:
+                                info = getContext().getString(R.string.send_failed);
+                                break;
+                        }
+                    } else {
+                        info = getContext().getString(R.string.send_failed);
+                    }
+                } else {
+                    info = getContext().getString(R.string.send_failed);
+                }
+                error = true;
+                break;
+            default:
+                if (multiReceived) {
+                    info = UIHelper.getMessageDisplayName(message);
+                }
+                break;
+        }
+        if (error && type == SENT) {
+            if (darkBackground) {
+                viewHolder.time.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Caption_Warning_OnDark);
+            } else {
+                viewHolder.time.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Caption_Warning);
+            }
+        } else {
+            if (darkBackground) {
+                viewHolder.time.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Caption_OnDark);
+            } else {
+                viewHolder.time.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Caption);
+            }
+            viewHolder.time.setTextColor(this.getMessageTextColor(darkBackground, false));
+        }
+        if (message.getEncryption() == Message.ENCRYPTION_NONE) {
+            viewHolder.indicator.setVisibility(View.GONE);
+        } else {
+            boolean verified = false;
+            if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
+                final FingerprintStatus status = message.getConversation()
+                        .getAccount().getAxolotlService().getFingerprintTrust(
+                                message.getFingerprint());
+                if (status != null && status.isVerified()) {
+                    verified = true;
+                }
+            }
+            if (verified) {
+                viewHolder.indicator.setImageResource(darkBackground ? R.drawable.ic_verified_user_white_18dp : R.drawable.ic_verified_user_black_18dp);
+            } else {
+                viewHolder.indicator.setImageResource(darkBackground ? R.drawable.ic_lock_white_18dp : R.drawable.ic_lock_black_18dp);
+            }
+            if (darkBackground) {
+                viewHolder.indicator.setAlpha(0.7f);
+            } else {
+                viewHolder.indicator.setAlpha(0.57f);
+            }
+            viewHolder.indicator.setVisibility(View.VISIBLE);
+        }
+
+        final String formattedTime = UIHelper.readableTimeDifferenceFull(getContext(), message.getMergedTimeSent());
+        final String bodyLanguage = message.getBodyLanguage();
+        final String bodyLanguageInfo = bodyLanguage == null ? "" : String.format(" \u00B7 %s", bodyLanguage.toUpperCase(Locale.US));
+        if (message.getStatus() <= Message.STATUS_RECEIVED) {
+            if ((filesize != null) && (info != null)) {
+                viewHolder.time.setText(formattedTime + " \u00B7 " + filesize + " \u00B7 " + info + bodyLanguageInfo);
+            } else if ((filesize == null) && (info != null)) {
+                viewHolder.time.setText(formattedTime + " \u00B7 " + info + bodyLanguageInfo);
+            } else if ((filesize != null) && (info == null)) {
+                viewHolder.time.setText(formattedTime + " \u00B7 " + filesize + bodyLanguageInfo);
+            } else {
+                viewHolder.time.setText(formattedTime + bodyLanguageInfo);
+            }
+        } else {
+            if ((filesize != null) && (info != null)) {
+                viewHolder.time.setText(filesize + " \u00B7 " + info + bodyLanguageInfo);
+            } else if ((filesize == null) && (info != null)) {
+                if (error) {
+                    viewHolder.time.setText(info + " \u00B7 " + formattedTime + bodyLanguageInfo);
+                } else {
+                    viewHolder.time.setText(info);
+                }
+            } else if ((filesize != null) && (info == null)) {
+                viewHolder.time.setText(filesize + " \u00B7 " + formattedTime + bodyLanguageInfo);
+            } else {
+                viewHolder.time.setText(formattedTime + bodyLanguageInfo);
+            }
+        }
+    }
+
+    private void displayInfoMessage(ViewHolder viewHolder, CharSequence text, boolean darkBackground) {
+        viewHolder.download_button.setVisibility(View.GONE);
+        viewHolder.audioPlayer.setVisibility(View.GONE);
+        viewHolder.image.setVisibility(View.GONE);
+        viewHolder.messageBody.setVisibility(View.VISIBLE);
+        viewHolder.messageBody.setText(text);
+        if (darkBackground) {
+            viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_Secondary_OnDark);
+        } else {
+            viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_Secondary);
+        }
+        viewHolder.messageBody.setTextIsSelectable(false);
+    }
+
+    private void displayEmojiMessage(final ViewHolder viewHolder, final String body, final boolean darkBackground) {
+        viewHolder.download_button.setVisibility(View.GONE);
+        viewHolder.audioPlayer.setVisibility(View.GONE);
+        viewHolder.image.setVisibility(View.GONE);
+        viewHolder.messageBody.setVisibility(View.VISIBLE);
+        if (darkBackground) {
+            viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_Emoji_OnDark);
+        } else {
+            viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_Emoji);
+        }
+        Spannable span = new SpannableString(body);
+        float size = Emoticons.isEmoji(body) ? 3.0f : 2.0f;
+        span.setSpan(new RelativeSizeSpan(size), 0, body.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+        viewHolder.messageBody.setText(EmojiWrapper.transform(span));
+    }
+
+    private void applyQuoteSpan(SpannableStringBuilder body, int start, int end, boolean darkBackground) {
+        if (start > 1 && !"\n\n".equals(body.subSequence(start - 2, start).toString())) {
+            body.insert(start++, "\n");
+            body.setSpan(new DividerSpan(false), start - 2, start, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+            end++;
+        }
+        if (end < body.length() - 1 && !"\n\n".equals(body.subSequence(end, end + 2).toString())) {
+            body.insert(end, "\n");
+            body.setSpan(new DividerSpan(false), end, end + 2, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+        }
+        int color = darkBackground ? this.getMessageTextColor(darkBackground, false)
+                : ContextCompat.getColor(activity, R.color.green700_desaturated);
+        DisplayMetrics metrics = getContext().getResources().getDisplayMetrics();
+        body.setSpan(new QuoteSpan(color, metrics), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+    }
+
+    /**
+     * Applies QuoteSpan to group of lines which starts with > or ยป characters.
+     * Appends likebreaks and applies DividerSpan to them to show a padding between quote and text.
+     */
+    private boolean handleTextQuotes(SpannableStringBuilder body, boolean darkBackground) {
+        boolean startsWithQuote = false;
+        char previous = '\n';
+        int lineStart = -1;
+        int lineTextStart = -1;
+        int quoteStart = -1;
+        for (int i = 0; i <= body.length(); i++) {
+            char current = body.length() > i ? body.charAt(i) : '\n';
+            if (lineStart == -1) {
+                if (previous == '\n') {
+                    if ((current == '>' && UIHelper.isPositionFollowedByQuoteableCharacter(body, i))
+                            || current == '\u00bb' && !UIHelper.isPositionFollowedByQuote(body, i)) {
+                        // Line start with quote
+                        lineStart = i;
+                        if (quoteStart == -1) quoteStart = i;
+                        if (i == 0) startsWithQuote = true;
+                    } else if (quoteStart >= 0) {
+                        // Line start without quote, apply spans there
+                        applyQuoteSpan(body, quoteStart, i - 1, darkBackground);
+                        quoteStart = -1;
+                    }
+                }
+            } else {
+                // Remove extra spaces between > and first character in the line
+                // > character will be removed too
+                if (current != ' ' && lineTextStart == -1) {
+                    lineTextStart = i;
+                }
+                if (current == '\n') {
+                    body.delete(lineStart, lineTextStart);
+                    i -= lineTextStart - lineStart;
+                    if (i == lineStart) {
+                        // Avoid empty lines because span over empty line can be hidden
+                        body.insert(i++, " ");
+                    }
+                    lineStart = -1;
+                    lineTextStart = -1;
+                }
+            }
+            previous = current;
+        }
+        if (quoteStart >= 0) {
+            // Apply spans to finishing open quote
+            applyQuoteSpan(body, quoteStart, body.length(), darkBackground);
+        }
+        return startsWithQuote;
+    }
+
+    private void displayTextMessage(final ViewHolder viewHolder, final Message message, boolean darkBackground, int type) {
+        viewHolder.download_button.setVisibility(View.GONE);
+        viewHolder.image.setVisibility(View.GONE);
+        viewHolder.audioPlayer.setVisibility(View.GONE);
+        viewHolder.messageBody.setVisibility(View.VISIBLE);
+
+        if (darkBackground) {
+            viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_OnDark);
+        } else {
+            viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1);
+        }
+        viewHolder.messageBody.setHighlightColor(ContextCompat.getColor(activity, darkBackground
+                ? (type == SENT || !mUseGreenBackground ? R.color.black26 : R.color.grey800) : R.color.grey500));
+        viewHolder.messageBody.setTypeface(null, Typeface.NORMAL);
+
+        if (message.getBody() != null) {
+            final String nick = UIHelper.getMessageDisplayName(message);
+            SpannableStringBuilder body = message.getMergedBody();
+            boolean hasMeCommand = message.hasMeCommand();
+            if (hasMeCommand) {
+                body = body.replace(0, Message.ME_COMMAND.length(), nick + " ");
+            }
+            if (body.length() > Config.MAX_DISPLAY_MESSAGE_CHARS) {
+                body = new SpannableStringBuilder(body, 0, Config.MAX_DISPLAY_MESSAGE_CHARS);
+                body.append("\u2026");
+            }
+            Message.MergeSeparator[] mergeSeparators = body.getSpans(0, body.length(), Message.MergeSeparator.class);
+            for (Message.MergeSeparator mergeSeparator : mergeSeparators) {
+                int start = body.getSpanStart(mergeSeparator);
+                int end = body.getSpanEnd(mergeSeparator);
+                body.setSpan(new DividerSpan(true), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+            }
+            boolean startsWithQuote = handleTextQuotes(body, darkBackground);
+            if (!message.isPrivateMessage()) {
+                if (hasMeCommand) {
+                    body.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), 0, nick.length(),
+                            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+                }
+            } else {
+                String privateMarker;
+                if (message.getStatus() <= Message.STATUS_RECEIVED) {
+                    privateMarker = activity.getString(R.string.private_message);
+                } else {
+                    Jid cp = message.getCounterpart();
+                    privateMarker = activity.getString(R.string.private_message_to, Strings.nullToEmpty(cp == null ? null : cp.getResource()));
+                }
+                body.insert(0, privateMarker);
+                int privateMarkerIndex = privateMarker.length();
+                if (startsWithQuote) {
+                    body.insert(privateMarkerIndex, "\n\n");
+                    body.setSpan(new DividerSpan(false), privateMarkerIndex, privateMarkerIndex + 2,
+                            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+                } else {
+                    body.insert(privateMarkerIndex, " ");
+                }
+                body.setSpan(new ForegroundColorSpan(getMessageTextColor(darkBackground, false)), 0, privateMarkerIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+                body.setSpan(new StyleSpan(Typeface.BOLD), 0, privateMarkerIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+                if (hasMeCommand) {
+                    body.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), privateMarkerIndex + 1,
+                            privateMarkerIndex + 1 + nick.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+                }
+            }
+            if (message.getConversation().getMode() == Conversation.MODE_MULTI && message.getStatus() == Message.STATUS_RECEIVED) {
+                if (message.getConversation() instanceof Conversation) {
+                    final Conversation conversation = (Conversation) message.getConversation();
+                    Pattern pattern = NotificationService.generateNickHighlightPattern(conversation.getMucOptions().getActualNick());
+                    Matcher matcher = pattern.matcher(body);
+                    while (matcher.find()) {
+                        body.setSpan(new StyleSpan(Typeface.BOLD), matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+                    }
+                }
+            }
+            Matcher matcher = Emoticons.getEmojiPattern(body).matcher(body);
+            while (matcher.find()) {
+                if (matcher.start() < matcher.end()) {
+                    body.setSpan(new RelativeSizeSpan(1.2f), matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+                }
+            }
+
+            StylingHelper.format(body, viewHolder.messageBody.getCurrentTextColor());
+            if (highlightedTerm != null) {
+                StylingHelper.highlight(activity, body, highlightedTerm, StylingHelper.isDarkText(viewHolder.messageBody));
+            }
+            MyLinkify.addLinks(body, true);
+            viewHolder.messageBody.setAutoLinkMask(0);
+            viewHolder.messageBody.setText(EmojiWrapper.transform(body));
+            viewHolder.messageBody.setTextIsSelectable(true);
+            viewHolder.messageBody.setMovementMethod(ClickableMovementMethod.getInstance());
+            listSelectionManager.onUpdate(viewHolder.messageBody, message);
+        } else {
+            viewHolder.messageBody.setText("");
+            viewHolder.messageBody.setTextIsSelectable(false);
+        }
+    }
+
+    private void displayDownloadableMessage(ViewHolder viewHolder, final Message message, String text, final boolean darkBackground) {
+        toggleWhisperInfo(viewHolder, message, darkBackground);
+        viewHolder.image.setVisibility(View.GONE);
+        viewHolder.audioPlayer.setVisibility(View.GONE);
+        viewHolder.download_button.setVisibility(View.VISIBLE);
+        viewHolder.download_button.setText(text);
+        viewHolder.download_button.setOnClickListener(v -> ConversationFragment.downloadFile(activity, message));
+    }
+
+    private void displayOpenableMessage(ViewHolder viewHolder, final Message message, final boolean darkBackground) {
+        toggleWhisperInfo(viewHolder, message, darkBackground);
+        viewHolder.image.setVisibility(View.GONE);
+        viewHolder.audioPlayer.setVisibility(View.GONE);
+        viewHolder.download_button.setVisibility(View.VISIBLE);
+        viewHolder.download_button.setText(activity.getString(R.string.open_x_file, UIHelper.getFileDescriptionString(activity, message)));
+        viewHolder.download_button.setOnClickListener(v -> openDownloadable(message));
+    }
+
+    private void displayLocationMessage(ViewHolder viewHolder, final Message message, final boolean darkBackground) {
+        toggleWhisperInfo(viewHolder, message, darkBackground);
+        viewHolder.image.setVisibility(View.GONE);
+        viewHolder.audioPlayer.setVisibility(View.GONE);
+        viewHolder.download_button.setVisibility(View.VISIBLE);
+        viewHolder.download_button.setText(R.string.show_location);
+        viewHolder.download_button.setOnClickListener(v -> showLocation(message));
+    }
+
+    private void displayAudioMessage(ViewHolder viewHolder, Message message, boolean darkBackground) {
+        toggleWhisperInfo(viewHolder, message, darkBackground);
+        viewHolder.image.setVisibility(View.GONE);
+        viewHolder.download_button.setVisibility(View.GONE);
+        final RelativeLayout audioPlayer = viewHolder.audioPlayer;
+        audioPlayer.setVisibility(View.VISIBLE);
+        AudioPlayer.ViewHolder.get(audioPlayer).setDarkBackground(darkBackground);
+        this.audioPlayer.init(audioPlayer, message);
+    }
+
+    private void displayMediaPreviewMessage(ViewHolder viewHolder, final Message message, final boolean darkBackground) {
+        toggleWhisperInfo(viewHolder, message, darkBackground);
+        viewHolder.download_button.setVisibility(View.GONE);
+        viewHolder.audioPlayer.setVisibility(View.GONE);
+        viewHolder.image.setVisibility(View.VISIBLE);
+        final FileParams params = message.getFileParams();
+        final double target = metrics.density * 288;
+        final int scaledW;
+        final int scaledH;
+        if (Math.max(params.height, params.width) * metrics.density <= target) {
+            scaledW = (int) (params.width * metrics.density);
+            scaledH = (int) (params.height * metrics.density);
+        } else if (Math.max(params.height, params.width) <= target) {
+            scaledW = params.width;
+            scaledH = params.height;
+        } else if (params.width <= params.height) {
+            scaledW = (int) (params.width / ((double) params.height / target));
+            scaledH = (int) target;
+        } else {
+            scaledW = (int) target;
+            scaledH = (int) (params.height / ((double) params.width / target));
+        }
+        LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(scaledW, scaledH);
+        layoutParams.setMargins(0, (int) (metrics.density * 4), 0, (int) (metrics.density * 4));
+        viewHolder.image.setLayoutParams(layoutParams);
+        activity.loadBitmap(message, viewHolder.image);
+        viewHolder.image.setOnClickListener(v -> openDownloadable(message));
+    }
+
+    private void toggleWhisperInfo(ViewHolder viewHolder, final Message message, final boolean darkBackground) {
+        if (message.isPrivateMessage()) {
+            final String privateMarker;
+            if (message.getStatus() <= Message.STATUS_RECEIVED) {
+                privateMarker = activity.getString(R.string.private_message);
+            } else {
+                Jid cp = message.getCounterpart();
+                privateMarker = activity.getString(R.string.private_message_to, Strings.nullToEmpty(cp == null ? null : cp.getResource()));
+            }
+            final SpannableString body = new SpannableString(privateMarker);
+            body.setSpan(new ForegroundColorSpan(getMessageTextColor(darkBackground, false)), 0, privateMarker.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+            body.setSpan(new StyleSpan(Typeface.BOLD), 0, privateMarker.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+            viewHolder.messageBody.setText(body);
+            viewHolder.messageBody.setVisibility(View.VISIBLE);
+        } else {
+            viewHolder.messageBody.setVisibility(View.GONE);
+        }
+    }
+
+    private void loadMoreMessages(Conversation conversation) {
+        conversation.setLastClearHistory(0, null);
+        activity.xmppConnectionService.updateConversation(conversation);
+        conversation.setHasMessagesLeftOnServer(true);
+        conversation.setFirstMamReference(null);
+        long timestamp = conversation.getLastMessageTransmitted().getTimestamp();
+        if (timestamp == 0) {
+            timestamp = System.currentTimeMillis();
+        }
+        conversation.messagesLoaded.set(true);
+        MessageArchiveService.Query query = activity.xmppConnectionService.getMessageArchiveService().query(conversation, new MamReference(0), timestamp, false);
+        if (query != null) {
+            Toast.makeText(activity, R.string.fetching_history_from_server, Toast.LENGTH_LONG).show();
+        } else {
+            Toast.makeText(activity, R.string.not_fetching_history_retention_period, Toast.LENGTH_SHORT).show();
+        }
+    }
+
+    @Override
+    public View getView(int position, View view, ViewGroup parent) {
+        final Message message = getItem(position);
+        final boolean omemoEncryption = message.getEncryption() == Message.ENCRYPTION_AXOLOTL;
+        final boolean isInValidSession = message.isValidInSession() && (!omemoEncryption || message.isTrusted());
+        final Conversational conversation = message.getConversation();
+        final Account account = conversation.getAccount();
+        final int type = getItemViewType(position);
+        ViewHolder viewHolder;
+        if (view == null) {
+            viewHolder = new ViewHolder();
+            switch (type) {
+                case DATE_SEPARATOR:
+                    view = activity.getLayoutInflater().inflate(R.layout.message_date_bubble, parent, false);
+                    viewHolder.status_message = view.findViewById(R.id.message_body);
+                    viewHolder.message_box = view.findViewById(R.id.message_box);
+                    viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
+                    break;
+				case RTP_SESSION:
+					view = activity.getLayoutInflater().inflate(R.layout.message_rtp_session, parent, false);
 					viewHolder.status_message = view.findViewById(R.id.message_body);
 					viewHolder.message_box = view.findViewById(R.id.message_box);
-					break;
-				case SENT:
-					view = activity.getLayoutInflater().inflate(R.layout.message_sent, parent, false);
-					viewHolder.message_box = view.findViewById(R.id.message_box);
-					viewHolder.contact_picture = view.findViewById(R.id.message_photo);
-					viewHolder.download_button = view.findViewById(R.id.download_button);
-					viewHolder.indicator = view.findViewById(R.id.security_indicator);
-					viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
-					viewHolder.image = view.findViewById(R.id.message_image);
-					viewHolder.messageBody = view.findViewById(R.id.message_body);
-					viewHolder.time = view.findViewById(R.id.message_time);
-					viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
-					viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
-					break;
-				case RECEIVED:
-					view = activity.getLayoutInflater().inflate(R.layout.message_received, parent, false);
-					viewHolder.message_box = view.findViewById(R.id.message_box);
-					viewHolder.contact_picture = view.findViewById(R.id.message_photo);
-					viewHolder.download_button = view.findViewById(R.id.download_button);
-					viewHolder.indicator = view.findViewById(R.id.security_indicator);
-					viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
-					viewHolder.image = view.findViewById(R.id.message_image);
-					viewHolder.messageBody = view.findViewById(R.id.message_body);
-					viewHolder.time = view.findViewById(R.id.message_time);
 					viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
-					viewHolder.encryption = view.findViewById(R.id.message_encryption);
-					viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
-					break;
-				case STATUS:
-					view = activity.getLayoutInflater().inflate(R.layout.message_status, parent, false);
-					viewHolder.contact_picture = view.findViewById(R.id.message_photo);
-					viewHolder.status_message = view.findViewById(R.id.status_message);
-					viewHolder.load_more_messages = view.findViewById(R.id.load_more_messages);
 					break;
-				default:
-					throw new AssertionError("Unknown view type");
-			}
-			if (viewHolder.messageBody != null) {
-				listSelectionManager.onCreate(viewHolder.messageBody,
-						new MessageBodyActionModeCallback(viewHolder.messageBody));
-				viewHolder.messageBody.setCopyHandler(this);
-			}
-			view.setTag(viewHolder);
-		} else {
-			viewHolder = (ViewHolder) view.getTag();
-			if (viewHolder == null) {
-				return view;
-			}
-		}
-
-		boolean darkBackground = type == RECEIVED && (!isInValidSession || mUseGreenBackground) || activity.isDarkTheme();
-
-		if (type == DATE_SEPARATOR) {
-			if (UIHelper.today(message.getTimeSent())) {
-				viewHolder.status_message.setText(R.string.today);
-			} else if (UIHelper.yesterday(message.getTimeSent())) {
-				viewHolder.status_message.setText(R.string.yesterday);
-			} else {
-				viewHolder.status_message.setText(DateUtils.formatDateTime(activity, message.getTimeSent(), DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR));
-			}
-			viewHolder.message_box.setBackgroundResource(activity.isDarkTheme() ? R.drawable.date_bubble_grey : R.drawable.date_bubble_white);
-			return view;
-		} else if (type == STATUS) {
-			if ("LOAD_MORE".equals(message.getBody())) {
-				viewHolder.status_message.setVisibility(View.GONE);
-				viewHolder.contact_picture.setVisibility(View.GONE);
-				viewHolder.load_more_messages.setVisibility(View.VISIBLE);
-				viewHolder.load_more_messages.setOnClickListener(v -> loadMoreMessages((Conversation) message.getConversation()));
-			} else {
-				viewHolder.status_message.setVisibility(View.VISIBLE);
-				viewHolder.load_more_messages.setVisibility(View.GONE);
-				viewHolder.status_message.setText(message.getBody());
-				boolean showAvatar;
-				if (conversation.getMode() == Conversation.MODE_SINGLE) {
-					showAvatar = true;
-					AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
-				} else if (message.getCounterpart() != null || message.getTrueCounterpart() != null || (message.getCounterparts() != null && message.getCounterparts().size() > 0)) {
-					showAvatar = true;
-					AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
-				} else {
-					showAvatar = false;
-				}
-				if (showAvatar) {
-					viewHolder.contact_picture.setAlpha(0.5f);
-					viewHolder.contact_picture.setVisibility(View.VISIBLE);
-				} else {
-					viewHolder.contact_picture.setVisibility(View.GONE);
-				}
-			}
-			return view;
-		} else {
-			AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar);
-		}
-
-		resetClickListener(viewHolder.message_box, viewHolder.messageBody);
-
-		viewHolder.contact_picture.setOnClickListener(v -> {
-			if (MessageAdapter.this.mOnContactPictureClickedListener != null) {
-				MessageAdapter.this.mOnContactPictureClickedListener
-						.onContactPictureClicked(message);
-			}
-
-		});
-		viewHolder.contact_picture.setOnLongClickListener(v -> {
-			if (MessageAdapter.this.mOnContactPictureLongClickedListener != null) {
-				MessageAdapter.this.mOnContactPictureLongClickedListener
-						.onContactPictureLongClicked(v, message);
-				return true;
-			} else {
-				return false;
-			}
-		});
-
-		final Transferable transferable = message.getTransferable();
-		final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
-		if (unInitiatedButKnownSize || message.isDeleted() || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING)) {
-			if (unInitiatedButKnownSize || transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER) {
-				displayDownloadableMessage(viewHolder, message, activity.getString(R.string.download_x_file, UIHelper.getFileDescriptionString(activity, message)), darkBackground);
-			} else if (transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER_CHECK_FILESIZE) {
-				displayDownloadableMessage(viewHolder, message, activity.getString(R.string.check_x_filesize, UIHelper.getFileDescriptionString(activity, message)), darkBackground);
-			} else {
-				displayInfoMessage(viewHolder, UIHelper.getMessagePreview(activity, message).first, darkBackground);
-			}
-		} else if (message.isFileOrImage() && message.getEncryption() != Message.ENCRYPTION_PGP && message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED) {
-			if (message.getFileParams().width > 0 && message.getFileParams().height > 0) {
-				displayMediaPreviewMessage(viewHolder, message, darkBackground);
-			} else if (message.getFileParams().runtime > 0) {
-				displayAudioMessage(viewHolder, message, darkBackground);
-			} else {
-				displayOpenableMessage(viewHolder, message, darkBackground);
-			}
-		} else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
-			if (account.isPgpDecryptionServiceConnected()) {
-				if (conversation instanceof Conversation && !account.hasPendingPgpIntent((Conversation) conversation)) {
-					displayInfoMessage(viewHolder, activity.getString(R.string.message_decrypting), darkBackground);
-				} else {
-					displayInfoMessage(viewHolder, activity.getString(R.string.pgp_message), darkBackground);
-				}
-			} else {
-				displayInfoMessage(viewHolder, activity.getString(R.string.install_openkeychain), darkBackground);
-				viewHolder.message_box.setOnClickListener(this::promptOpenKeychainInstall);
-				viewHolder.messageBody.setOnClickListener(this::promptOpenKeychainInstall);
-			}
-		} else if (message.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) {
-			displayInfoMessage(viewHolder, activity.getString(R.string.decryption_failed), darkBackground);
-		} else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE) {
-			displayInfoMessage(viewHolder, activity.getString(R.string.not_encrypted_for_this_device), darkBackground);
-		} else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_FAILED) {
-			displayInfoMessage(viewHolder, activity.getString(R.string.omemo_decryption_failed), darkBackground);
-		} else {
-			if (message.isGeoUri()) {
-				displayLocationMessage(viewHolder, message, darkBackground);
-			} else if (message.bodyIsOnlyEmojis() && message.getType() != Message.TYPE_PRIVATE) {
-				displayEmojiMessage(viewHolder, message.getBody().trim(), darkBackground);
-			} else if (message.treatAsDownloadable()) {
-				try {
-					URL url = new URL(message.getBody());
-					if (P1S3UrlStreamHandler.PROTOCOL_NAME.equalsIgnoreCase(url.getProtocol())) {
-						displayDownloadableMessage(viewHolder,
-								message,
-								activity.getString(R.string.check_x_filesize,
-										UIHelper.getFileDescriptionString(activity, message)),
-								darkBackground);
-					} else {
-						displayDownloadableMessage(viewHolder,
-								message,
-								activity.getString(R.string.check_x_filesize_on_host,
-										UIHelper.getFileDescriptionString(activity, message),
-										url.getHost()),
-								darkBackground);
-					}
-				} catch (Exception e) {
-					displayDownloadableMessage(viewHolder,
-							message,
-							activity.getString(R.string.check_x_filesize,
-									UIHelper.getFileDescriptionString(activity, message)),
-							darkBackground);
-				}
-			} else {
-				displayTextMessage(viewHolder, message, darkBackground, type);
-			}
-		}
-
-		if (type == RECEIVED) {
-			if (isInValidSession) {
-				int bubble;
-				if (!mUseGreenBackground) {
-					bubble = activity.getThemeResource(R.attr.message_bubble_received_monochrome, R.drawable.message_bubble_received_white);
-				} else {
-					bubble = activity.getThemeResource(R.attr.message_bubble_received_green, R.drawable.message_bubble_received);
-				}
-				viewHolder.message_box.setBackgroundResource(bubble);
-				viewHolder.encryption.setVisibility(View.GONE);
-			} else {
-				viewHolder.message_box.setBackgroundResource(R.drawable.message_bubble_received_warning);
-				viewHolder.encryption.setVisibility(View.VISIBLE);
-				if (omemoEncryption && !message.isTrusted()) {
-					viewHolder.encryption.setText(R.string.not_trusted);
-				} else {
-					viewHolder.encryption.setText(CryptoHelper.encryptionTypeToText(message.getEncryption()));
-				}
-			}
-		}
-
-		displayStatus(viewHolder, message, type, darkBackground);
-
-		return view;
-	}
-
-	private void promptOpenKeychainInstall(View view) {
-		activity.showInstallPgpDialog();
-	}
-
-	@Override
-	public void notifyDataSetChanged() {
-		listSelectionManager.onBeforeNotifyDataSetChanged();
-		super.notifyDataSetChanged();
-		listSelectionManager.onAfterNotifyDataSetChanged();
-	}
-
-	private String transformText(CharSequence text, int start, int end, boolean forCopy) {
-		SpannableStringBuilder builder = new SpannableStringBuilder(text);
-		Object copySpan = new Object();
-		builder.setSpan(copySpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-		DividerSpan[] dividerSpans = builder.getSpans(0, builder.length(), DividerSpan.class);
-		for (DividerSpan dividerSpan : dividerSpans) {
-			builder.replace(builder.getSpanStart(dividerSpan), builder.getSpanEnd(dividerSpan),
-					dividerSpan.isLarge() ? "\n\n" : "\n");
-		}
-		start = builder.getSpanStart(copySpan);
-		end = builder.getSpanEnd(copySpan);
-		if (start == -1 || end == -1) return "";
-		builder = new SpannableStringBuilder(builder, start, end);
-		if (forCopy) {
-			QuoteSpan[] quoteSpans = builder.getSpans(0, builder.length(), QuoteSpan.class);
-			for (QuoteSpan quoteSpan : quoteSpans) {
-				builder.insert(builder.getSpanStart(quoteSpan), "> ");
-			}
-		}
-		return builder.toString();
-	}
-
-	@Override
-	public String transformTextForCopy(CharSequence text, int start, int end) {
-		if (text instanceof Spanned) {
-			return transformText(text, start, end, true);
-		} else {
-			return text.toString().substring(start, end);
-		}
-	}
-
-	public FileBackend getFileBackend() {
-		return activity.xmppConnectionService.getFileBackend();
-	}
-
-	public void stopAudioPlayer() {
-		audioPlayer.stop();
-	}
-
-	public void unregisterListenerInAudioPlayer() {
-		audioPlayer.unregisterListener();
-	}
-
-	public void startStopPending() {
-		audioPlayer.startStopPending();
-	}
-
-	public void openDownloadable(Message message) {
-		if (ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
-			ConversationFragment.registerPendingMessage(activity, message);
-			ActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, ConversationsActivity.REQUEST_OPEN_MESSAGE);
-			return;
-		}
-		final DownloadableFile file = activity.xmppConnectionService.getFileBackend().getFile(message);
-		ViewUtil.view(activity, file);
-	}
-
-	private void showLocation(Message message) {
-		for (Intent intent : GeoHelper.createGeoIntentsFromMessage(activity, message)) {
-			if (intent.resolveActivity(getContext().getPackageManager()) != null) {
-				getContext().startActivity(intent);
-				return;
-			}
-		}
-		Toast.makeText(activity, R.string.no_application_found_to_display_location, Toast.LENGTH_SHORT).show();
-	}
-
-	public void updatePreferences() {
-		SharedPreferences p = PreferenceManager.getDefaultSharedPreferences(activity);
-		this.mUseGreenBackground = p.getBoolean("use_green_background", activity.getResources().getBoolean(R.bool.use_green_background));
-	}
-
-
-	public void setHighlightedTerm(List<String> terms) {
-		this.highlightedTerm = terms == null ? null : StylingHelper.filterHighlightedWords(terms);
-	}
-
-	public interface OnQuoteListener {
-		void onQuote(String text);
-	}
-
-	public interface OnContactPictureClicked {
-		void onContactPictureClicked(Message message);
-	}
-
-	public interface OnContactPictureLongClicked {
-		void onContactPictureLongClicked(View v, Message message);
-	}
-
-	private static class ViewHolder {
-
-		public Button load_more_messages;
-		public ImageView edit_indicator;
-		public RelativeLayout audioPlayer;
-		protected LinearLayout message_box;
-		protected Button download_button;
-		protected ImageView image;
-		protected ImageView indicator;
-		protected ImageView indicatorReceived;
-		protected TextView time;
-		protected CopyTextView messageBody;
-		protected ImageView contact_picture;
-		protected TextView status_message;
-		protected TextView encryption;
-	}
-
-
-	private class MessageBodyActionModeCallback implements ActionMode.Callback {
-
-		private final TextView textView;
-
-		public MessageBodyActionModeCallback(TextView textView) {
-			this.textView = textView;
-		}
-
-		@Override
-		public boolean onCreateActionMode(ActionMode mode, Menu menu) {
-			if (onQuoteListener != null) {
-				int quoteResId = activity.getThemeResource(R.attr.icon_quote, R.drawable.ic_action_reply);
-				// 3rd item is placed after "copy" item
-				menu.add(0, android.R.id.button1, 3, R.string.quote).setIcon(quoteResId)
-						.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS | MenuItem.SHOW_AS_ACTION_WITH_TEXT);
-			}
-			return false;
-		}
-
-		@Override
-		public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
-			return false;
-		}
-
-		@Override
-		public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
-			if (item.getItemId() == android.R.id.button1) {
-				int start = textView.getSelectionStart();
-				int end = textView.getSelectionEnd();
-				if (end > start) {
-					String text = transformText(textView.getText(), start, end, false);
-					if (onQuoteListener != null) {
-						onQuoteListener.onQuote(text);
-					}
-					mode.finish();
-				}
-				return true;
-			}
-			return false;
-		}
-
-		@Override
-		public void onDestroyActionMode(ActionMode mode) {
-		}
-	}
+                case SENT:
+                    view = activity.getLayoutInflater().inflate(R.layout.message_sent, parent, false);
+                    viewHolder.message_box = view.findViewById(R.id.message_box);
+                    viewHolder.contact_picture = view.findViewById(R.id.message_photo);
+                    viewHolder.download_button = view.findViewById(R.id.download_button);
+                    viewHolder.indicator = view.findViewById(R.id.security_indicator);
+                    viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
+                    viewHolder.image = view.findViewById(R.id.message_image);
+                    viewHolder.messageBody = view.findViewById(R.id.message_body);
+                    viewHolder.time = view.findViewById(R.id.message_time);
+                    viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
+                    viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
+                    break;
+                case RECEIVED:
+                    view = activity.getLayoutInflater().inflate(R.layout.message_received, parent, false);
+                    viewHolder.message_box = view.findViewById(R.id.message_box);
+                    viewHolder.contact_picture = view.findViewById(R.id.message_photo);
+                    viewHolder.download_button = view.findViewById(R.id.download_button);
+                    viewHolder.indicator = view.findViewById(R.id.security_indicator);
+                    viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
+                    viewHolder.image = view.findViewById(R.id.message_image);
+                    viewHolder.messageBody = view.findViewById(R.id.message_body);
+                    viewHolder.time = view.findViewById(R.id.message_time);
+                    viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
+                    viewHolder.encryption = view.findViewById(R.id.message_encryption);
+                    viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
+                    break;
+                case STATUS:
+                    view = activity.getLayoutInflater().inflate(R.layout.message_status, parent, false);
+                    viewHolder.contact_picture = view.findViewById(R.id.message_photo);
+                    viewHolder.status_message = view.findViewById(R.id.status_message);
+                    viewHolder.load_more_messages = view.findViewById(R.id.load_more_messages);
+                    break;
+                default:
+                    throw new AssertionError("Unknown view type");
+            }
+            if (viewHolder.messageBody != null) {
+                listSelectionManager.onCreate(viewHolder.messageBody,
+                        new MessageBodyActionModeCallback(viewHolder.messageBody));
+                viewHolder.messageBody.setCopyHandler(this);
+            }
+            view.setTag(viewHolder);
+        } else {
+            viewHolder = (ViewHolder) view.getTag();
+            if (viewHolder == null) {
+                return view;
+            }
+        }
+
+        boolean darkBackground = type == RECEIVED && (!isInValidSession || mUseGreenBackground) || activity.isDarkTheme();
+
+        if (type == DATE_SEPARATOR) {
+            if (UIHelper.today(message.getTimeSent())) {
+                viewHolder.status_message.setText(R.string.today);
+            } else if (UIHelper.yesterday(message.getTimeSent())) {
+                viewHolder.status_message.setText(R.string.yesterday);
+            } else {
+                viewHolder.status_message.setText(DateUtils.formatDateTime(activity, message.getTimeSent(), DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR));
+            }
+            viewHolder.message_box.setBackgroundResource(activity.isDarkTheme() ? R.drawable.date_bubble_grey : R.drawable.date_bubble_white);
+            return view;
+        } else if (type == RTP_SESSION) {
+            final boolean isDarkTheme = activity.isDarkTheme();
+            final boolean received = message.getStatus() <= Message.STATUS_RECEIVED;
+            final RtpSessionStatus rtpSessionStatus = RtpSessionStatus.of(message.getBody());
+            final long duration = rtpSessionStatus.duration;
+            if (received) {
+                if (duration > 0) {
+                    viewHolder.status_message.setText(activity.getString(R.string.incoming_call_duration, TimeframeUtils.resolve(activity,duration)));
+                } else {
+                    viewHolder.status_message.setText(R.string.incoming_call);
+                }
+            } else {
+                if (duration > 0) {
+                    viewHolder.status_message.setText(activity.getString(R.string.outgoing_call_duration, TimeframeUtils.resolve(activity,duration)));
+                } else {
+                    viewHolder.status_message.setText(R.string.outgoing_call);
+                }
+            }
+            viewHolder.indicatorReceived.setImageResource(RtpSessionStatus.getDrawable(received,rtpSessionStatus.successful,isDarkTheme));
+            viewHolder.indicatorReceived.setAlpha(isDarkTheme ? 0.7f : 0.57f);
+            viewHolder.message_box.setBackgroundResource(isDarkTheme ? R.drawable.date_bubble_grey : R.drawable.date_bubble_white);
+            return view;
+        } else if (type == STATUS) {
+            if ("LOAD_MORE".equals(message.getBody())) {
+                viewHolder.status_message.setVisibility(View.GONE);
+                viewHolder.contact_picture.setVisibility(View.GONE);
+                viewHolder.load_more_messages.setVisibility(View.VISIBLE);
+                viewHolder.load_more_messages.setOnClickListener(v -> loadMoreMessages((Conversation) message.getConversation()));
+            } else {
+                viewHolder.status_message.setVisibility(View.VISIBLE);
+                viewHolder.load_more_messages.setVisibility(View.GONE);
+                viewHolder.status_message.setText(message.getBody());
+                boolean showAvatar;
+                if (conversation.getMode() == Conversation.MODE_SINGLE) {
+                    showAvatar = true;
+                    AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
+                } else if (message.getCounterpart() != null || message.getTrueCounterpart() != null || (message.getCounterparts() != null && message.getCounterparts().size() > 0)) {
+                    showAvatar = true;
+                    AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
+                } else {
+                    showAvatar = false;
+                }
+                if (showAvatar) {
+                    viewHolder.contact_picture.setAlpha(0.5f);
+                    viewHolder.contact_picture.setVisibility(View.VISIBLE);
+                } else {
+                    viewHolder.contact_picture.setVisibility(View.GONE);
+                }
+            }
+            return view;
+        } else {
+            AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar);
+        }
+
+        resetClickListener(viewHolder.message_box, viewHolder.messageBody);
+
+        viewHolder.contact_picture.setOnClickListener(v -> {
+            if (MessageAdapter.this.mOnContactPictureClickedListener != null) {
+                MessageAdapter.this.mOnContactPictureClickedListener
+                        .onContactPictureClicked(message);
+            }
+
+        });
+        viewHolder.contact_picture.setOnLongClickListener(v -> {
+            if (MessageAdapter.this.mOnContactPictureLongClickedListener != null) {
+                MessageAdapter.this.mOnContactPictureLongClickedListener
+                        .onContactPictureLongClicked(v, message);
+                return true;
+            } else {
+                return false;
+            }
+        });
+
+        final Transferable transferable = message.getTransferable();
+        final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
+        if (unInitiatedButKnownSize || message.isDeleted() || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING)) {
+            if (unInitiatedButKnownSize || transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER) {
+                displayDownloadableMessage(viewHolder, message, activity.getString(R.string.download_x_file, UIHelper.getFileDescriptionString(activity, message)), darkBackground);
+            } else if (transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER_CHECK_FILESIZE) {
+                displayDownloadableMessage(viewHolder, message, activity.getString(R.string.check_x_filesize, UIHelper.getFileDescriptionString(activity, message)), darkBackground);
+            } else {
+                displayInfoMessage(viewHolder, UIHelper.getMessagePreview(activity, message).first, darkBackground);
+            }
+        } else if (message.isFileOrImage() && message.getEncryption() != Message.ENCRYPTION_PGP && message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED) {
+            if (message.getFileParams().width > 0 && message.getFileParams().height > 0) {
+                displayMediaPreviewMessage(viewHolder, message, darkBackground);
+            } else if (message.getFileParams().runtime > 0) {
+                displayAudioMessage(viewHolder, message, darkBackground);
+            } else {
+                displayOpenableMessage(viewHolder, message, darkBackground);
+            }
+        } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
+            if (account.isPgpDecryptionServiceConnected()) {
+                if (conversation instanceof Conversation && !account.hasPendingPgpIntent((Conversation) conversation)) {
+                    displayInfoMessage(viewHolder, activity.getString(R.string.message_decrypting), darkBackground);
+                } else {
+                    displayInfoMessage(viewHolder, activity.getString(R.string.pgp_message), darkBackground);
+                }
+            } else {
+                displayInfoMessage(viewHolder, activity.getString(R.string.install_openkeychain), darkBackground);
+                viewHolder.message_box.setOnClickListener(this::promptOpenKeychainInstall);
+                viewHolder.messageBody.setOnClickListener(this::promptOpenKeychainInstall);
+            }
+        } else if (message.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) {
+            displayInfoMessage(viewHolder, activity.getString(R.string.decryption_failed), darkBackground);
+        } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE) {
+            displayInfoMessage(viewHolder, activity.getString(R.string.not_encrypted_for_this_device), darkBackground);
+        } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_FAILED) {
+            displayInfoMessage(viewHolder, activity.getString(R.string.omemo_decryption_failed), darkBackground);
+        } else {
+            if (message.isGeoUri()) {
+                displayLocationMessage(viewHolder, message, darkBackground);
+            } else if (message.bodyIsOnlyEmojis() && message.getType() != Message.TYPE_PRIVATE) {
+                displayEmojiMessage(viewHolder, message.getBody().trim(), darkBackground);
+            } else if (message.treatAsDownloadable()) {
+                try {
+                    URL url = new URL(message.getBody());
+                    if (P1S3UrlStreamHandler.PROTOCOL_NAME.equalsIgnoreCase(url.getProtocol())) {
+                        displayDownloadableMessage(viewHolder,
+                                message,
+                                activity.getString(R.string.check_x_filesize,
+                                        UIHelper.getFileDescriptionString(activity, message)),
+                                darkBackground);
+                    } else {
+                        displayDownloadableMessage(viewHolder,
+                                message,
+                                activity.getString(R.string.check_x_filesize_on_host,
+                                        UIHelper.getFileDescriptionString(activity, message),
+                                        url.getHost()),
+                                darkBackground);
+                    }
+                } catch (Exception e) {
+                    displayDownloadableMessage(viewHolder,
+                            message,
+                            activity.getString(R.string.check_x_filesize,
+                                    UIHelper.getFileDescriptionString(activity, message)),
+                            darkBackground);
+                }
+            } else {
+                displayTextMessage(viewHolder, message, darkBackground, type);
+            }
+        }
+
+        if (type == RECEIVED) {
+            if (isInValidSession) {
+                int bubble;
+                if (!mUseGreenBackground) {
+                    bubble = activity.getThemeResource(R.attr.message_bubble_received_monochrome, R.drawable.message_bubble_received_white);
+                } else {
+                    bubble = activity.getThemeResource(R.attr.message_bubble_received_green, R.drawable.message_bubble_received);
+                }
+                viewHolder.message_box.setBackgroundResource(bubble);
+                viewHolder.encryption.setVisibility(View.GONE);
+            } else {
+                viewHolder.message_box.setBackgroundResource(R.drawable.message_bubble_received_warning);
+                viewHolder.encryption.setVisibility(View.VISIBLE);
+                if (omemoEncryption && !message.isTrusted()) {
+                    viewHolder.encryption.setText(R.string.not_trusted);
+                } else {
+                    viewHolder.encryption.setText(CryptoHelper.encryptionTypeToText(message.getEncryption()));
+                }
+            }
+        }
+
+        displayStatus(viewHolder, message, type, darkBackground);
+
+        return view;
+    }
+
+    private void promptOpenKeychainInstall(View view) {
+        activity.showInstallPgpDialog();
+    }
+
+    @Override
+    public void notifyDataSetChanged() {
+        listSelectionManager.onBeforeNotifyDataSetChanged();
+        super.notifyDataSetChanged();
+        listSelectionManager.onAfterNotifyDataSetChanged();
+    }
+
+    private String transformText(CharSequence text, int start, int end, boolean forCopy) {
+        SpannableStringBuilder builder = new SpannableStringBuilder(text);
+        Object copySpan = new Object();
+        builder.setSpan(copySpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+        DividerSpan[] dividerSpans = builder.getSpans(0, builder.length(), DividerSpan.class);
+        for (DividerSpan dividerSpan : dividerSpans) {
+            builder.replace(builder.getSpanStart(dividerSpan), builder.getSpanEnd(dividerSpan),
+                    dividerSpan.isLarge() ? "\n\n" : "\n");
+        }
+        start = builder.getSpanStart(copySpan);
+        end = builder.getSpanEnd(copySpan);
+        if (start == -1 || end == -1) return "";
+        builder = new SpannableStringBuilder(builder, start, end);
+        if (forCopy) {
+            QuoteSpan[] quoteSpans = builder.getSpans(0, builder.length(), QuoteSpan.class);
+            for (QuoteSpan quoteSpan : quoteSpans) {
+                builder.insert(builder.getSpanStart(quoteSpan), "> ");
+            }
+        }
+        return builder.toString();
+    }
+
+    @Override
+    public String transformTextForCopy(CharSequence text, int start, int end) {
+        if (text instanceof Spanned) {
+            return transformText(text, start, end, true);
+        } else {
+            return text.toString().substring(start, end);
+        }
+    }
+
+    public FileBackend getFileBackend() {
+        return activity.xmppConnectionService.getFileBackend();
+    }
+
+    public void stopAudioPlayer() {
+        audioPlayer.stop();
+    }
+
+    public void unregisterListenerInAudioPlayer() {
+        audioPlayer.unregisterListener();
+    }
+
+    public void startStopPending() {
+        audioPlayer.startStopPending();
+    }
+
+    public void openDownloadable(Message message) {
+        if (ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
+            ConversationFragment.registerPendingMessage(activity, message);
+            ActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, ConversationsActivity.REQUEST_OPEN_MESSAGE);
+            return;
+        }
+        final DownloadableFile file = activity.xmppConnectionService.getFileBackend().getFile(message);
+        ViewUtil.view(activity, file);
+    }
+
+    private void showLocation(Message message) {
+        for (Intent intent : GeoHelper.createGeoIntentsFromMessage(activity, message)) {
+            if (intent.resolveActivity(getContext().getPackageManager()) != null) {
+                getContext().startActivity(intent);
+                return;
+            }
+        }
+        Toast.makeText(activity, R.string.no_application_found_to_display_location, Toast.LENGTH_SHORT).show();
+    }
+
+    public void updatePreferences() {
+        SharedPreferences p = PreferenceManager.getDefaultSharedPreferences(activity);
+        this.mUseGreenBackground = p.getBoolean("use_green_background", activity.getResources().getBoolean(R.bool.use_green_background));
+    }
+
+
+    public void setHighlightedTerm(List<String> terms) {
+        this.highlightedTerm = terms == null ? null : StylingHelper.filterHighlightedWords(terms);
+    }
+
+    public interface OnQuoteListener {
+        void onQuote(String text);
+    }
+
+    public interface OnContactPictureClicked {
+        void onContactPictureClicked(Message message);
+    }
+
+    public interface OnContactPictureLongClicked {
+        void onContactPictureLongClicked(View v, Message message);
+    }
+
+    private static class ViewHolder {
+
+        public Button load_more_messages;
+        public ImageView edit_indicator;
+        public RelativeLayout audioPlayer;
+        protected LinearLayout message_box;
+        protected Button download_button;
+        protected ImageView image;
+        protected ImageView indicator;
+        protected ImageView indicatorReceived;
+        protected TextView time;
+        protected CopyTextView messageBody;
+        protected ImageView contact_picture;
+        protected TextView status_message;
+        protected TextView encryption;
+    }
+
+
+    private class MessageBodyActionModeCallback implements ActionMode.Callback {
+
+        private final TextView textView;
+
+        public MessageBodyActionModeCallback(TextView textView) {
+            this.textView = textView;
+        }
+
+        @Override
+        public boolean onCreateActionMode(ActionMode mode, Menu menu) {
+            if (onQuoteListener != null) {
+                int quoteResId = activity.getThemeResource(R.attr.icon_quote, R.drawable.ic_action_reply);
+                // 3rd item is placed after "copy" item
+                menu.add(0, android.R.id.button1, 3, R.string.quote).setIcon(quoteResId)
+                        .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS | MenuItem.SHOW_AS_ACTION_WITH_TEXT);
+            }
+            return false;
+        }
+
+        @Override
+        public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
+            return false;
+        }
+
+        @Override
+        public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
+            if (item.getItemId() == android.R.id.button1) {
+                int start = textView.getSelectionStart();
+                int end = textView.getSelectionEnd();
+                if (end > start) {
+                    String text = transformText(textView.getText(), start, end, false);
+                    if (onQuoteListener != null) {
+                        onQuoteListener.onQuote(text);
+                    }
+                    mode.finish();
+                }
+                return true;
+            }
+            return false;
+        }
+
+        @Override
+        public void onDestroyActionMode(ActionMode mode) {
+        }
+    }
 }

src/main/java/eu/siacs/conversations/utils/UIHelper.java ๐Ÿ”—

@@ -299,6 +299,8 @@ public class UIHelper {
 			return new Pair<>(context.getString(R.string.omemo_decryption_failed), true);
 		} else if (message.isFileOrImage()) {
 			return new Pair<>(getFileDescriptionString(context, message), true);
+		} else if (message.getType() == Message.TYPE_RTP_SESSION) {
+			return new Pair<>(context.getString(message.getStatus() == Message.STATUS_RECEIVED ? R.string.incoming_call : R.string.outgoing_call), true);
 		} else {
 			final String body = MessageUtils.filterLtrRtl(message.getBody());
 			if (body.startsWith(Message.ME_COMMAND)) {

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java ๐Ÿ”—

@@ -1,5 +1,6 @@
 package eu.siacs.conversations.xmpp.jingle;
 
+import android.os.SystemClock;
 import android.util.Log;
 
 import com.google.common.base.Strings;
@@ -18,6 +19,10 @@ import java.util.List;
 import java.util.Map;
 
 import eu.siacs.conversations.Config;
+import eu.siacs.conversations.entities.Conversation;
+import eu.siacs.conversations.entities.Conversational;
+import eu.siacs.conversations.entities.Message;
+import eu.siacs.conversations.entities.RtpSessionStatus;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
@@ -94,13 +99,27 @@ public class JingleRtpConnection extends AbstractJingleConnection implements Web
 
     private final WebRTCWrapper webRTCWrapper = new WebRTCWrapper(this);
     private final ArrayDeque<IceCandidate> pendingIceCandidates = new ArrayDeque<>();
+    private final Message message;
     private State state = State.NULL;
     private RtpContentMap initiatorRtpContentMap;
     private RtpContentMap responderRtpContentMap;
+    private long rtpConnectionStarted = 0; //time of 'connected'
 
 
     JingleRtpConnection(JingleConnectionManager jingleConnectionManager, Id id, Jid initiator) {
         super(jingleConnectionManager, id, initiator);
+        final Conversation conversation = jingleConnectionManager.getXmppConnectionService().findOrCreateConversation(
+                id.account,
+                id.with.asBareJid(),
+                false,
+                false
+        );
+        this.message = new Message(
+                conversation,
+                isInitiator() ? Message.STATUS_SEND : Message.STATUS_RECEIVED,
+                Message.TYPE_RTP_SESSION,
+                id.sessionId
+        );
     }
 
     private static State reasonToState(Reason reason) {
@@ -153,7 +172,9 @@ public class JingleRtpConnection extends AbstractJingleConnection implements Web
             return;
         }
         webRTCWrapper.close();
-        transitionOrThrow(reasonToState(wrapper.reason));
+        final State target = reasonToState(wrapper.reason);
+        transitionOrThrow(target);
+        writeLogMessage(target);
         if (previous == State.PROPOSED || previous == State.SESSION_INITIALIZED) {
             xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
         }
@@ -455,7 +476,7 @@ public class JingleRtpConnection extends AbstractJingleConnection implements Web
             if (transition(State.RETRACTED)) {
                 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
                 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": session with " + id.with + " has been retracted");
-                //TODO create missed call notification/message
+                writeLogMessageMissed();
                 jingleConnectionManager.finishConnection(this);
             } else {
                 Log.d(Config.LOGTAG, "ignoring retract because already in " + this.state);
@@ -509,6 +530,7 @@ public class JingleRtpConnection extends AbstractJingleConnection implements Web
     private void sendSessionTerminate(final Reason reason, final String text) {
         final State target = reasonToState(reason);
         transitionOrThrow(target);
+        writeLogMessage(target);
         final JinglePacket jinglePacket = new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
         jinglePacket.setReason(reason, text);
         send(jinglePacket);
@@ -773,9 +795,12 @@ public class JingleRtpConnection extends AbstractJingleConnection implements Web
     public void onConnectionChange(final PeerConnection.PeerConnectionState newState) {
         Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": PeerConnectionState changed to " + newState);
         updateEndUserState();
+        if (newState == PeerConnection.PeerConnectionState.CONNECTED && this.rtpConnectionStarted == 0) {
+            this.rtpConnectionStarted = SystemClock.elapsedRealtime();
+        }
         if (newState == PeerConnection.PeerConnectionState.FAILED) {
             if (TERMINATED.contains(this.state)) {
-                Log.d(Config.LOGTAG,id.account.getJid().asBareJid()+": not sending session-terminate after connectivity error because session is already in state "+this.state);
+                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": not sending session-terminate after connectivity error because session is already in state " + this.state);
                 return;
             }
             sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
@@ -850,6 +875,37 @@ public class JingleRtpConnection extends AbstractJingleConnection implements Web
         }
     }
 
+    private void writeLogMessage(final State state) {
+        final long started = this.rtpConnectionStarted;
+        long duration = started <= 0 ? 0 : SystemClock.elapsedRealtime() - started;
+        if (state == State.TERMINATED_SUCCESS || (state == State.TERMINATED_CONNECTIVITY_ERROR && duration > 0)) {
+            writeLogMessageSuccess(duration);
+        } else {
+            writeLogMessageMissed();
+        }
+    }
+
+    private void writeLogMessageSuccess(final long duration) {
+        this.message.setBody(new RtpSessionStatus(true, duration).toString());
+        this.writeMessage();
+    }
+
+    private void writeLogMessageMissed() {
+        this.message.setBody(new RtpSessionStatus(false,0).toString());
+        this.writeMessage();
+    }
+
+    private void writeMessage() {
+        final Conversational conversational = message.getConversation();
+        if (conversational instanceof Conversation) {
+            ((Conversation) conversational).add(this.message);
+            xmppConnectionService.databaseBackend.createMessage(message);
+            xmppConnectionService.updateConversationUi();
+        } else {
+            throw new IllegalStateException("Somehow the conversation in a message was a stub");
+        }
+    }
+
     public State getState() {
         return this.state;
     }

src/main/res/layout/message_date_bubble.xml ๐Ÿ”—

@@ -1,24 +1,27 @@
 <?xml version="1.0" encoding="utf-8"?>
 <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="fill_parent"
     android:layout_height="wrap_content"
     android:orientation="vertical"
-    android:paddingBottom="5dp"
     android:paddingLeft="8dp"
+    android:paddingTop="5dp"
     android:paddingRight="8dp"
-    android:paddingTop="5dp">
+    android:paddingBottom="5dp">
 
     <LinearLayout
+        android:id="@+id/message_box"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
-        android:background="@drawable/date_bubble_white"
-        android:id="@+id/message_box"
-        android:layout_centerHorizontal="true">
+        android:layout_centerHorizontal="true"
+        android:background="@drawable/date_bubble_white">
+
         <TextView
+            android:id="@+id/message_body"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:textAppearance="@style/TextAppearance.Conversations.Body1.Secondary"
-            android:id="@+id/message_body" />
+            tools:text="Yesterday" />
     </LinearLayout>
 
 </RelativeLayout>

src/main/res/layout/message_rtp_session.xml ๐Ÿ”—

@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="fill_parent"
+    android:layout_height="wrap_content"
+    android:orientation="vertical"
+    android:paddingLeft="8dp"
+    android:paddingTop="5dp"
+    android:paddingRight="8dp"
+    android:paddingBottom="5dp">
+
+    <LinearLayout
+        android:id="@+id/message_box"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_centerHorizontal="true"
+        android:background="@drawable/date_bubble_white"
+        android:gravity="center_vertical"
+        android:orientation="horizontal">
+
+        <ImageView
+            android:id="@+id/indicator_received"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginRight="4sp"
+            android:layout_marginLeft="0sp"
+            tools:alpha="0.57"
+            tools:src="@drawable/ic_call_received_black_18dp" />
+
+        <TextView
+            android:id="@+id/message_body"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            tools:text="@string/incoming_call"
+            android:textAppearance="@style/TextAppearance.Conversations.Body1.Secondary" />
+    </LinearLayout>
+
+</RelativeLayout>

src/main/res/values/strings.xml ๐Ÿ”—

@@ -903,6 +903,10 @@
     <string name="hang_up">Hang up</string>
     <string name="ongoing_call">Ongoing call</string>
     <string name="disable_tor_to_make_call">Disable Tor to make calls</string>
+    <string name="incoming_call">Incoming call</string>
+    <string name="incoming_call_duration">Incoming call ยท %s</string>
+    <string name="outgoing_call">Outgoing call</string>
+    <string name="outgoing_call_duration">Outgoing call ยท %s</string>
     <plurals name="view_users">
         <item quantity="one">View %1$d Participant</item>
         <item quantity="other">View %1$d Participants</item>