MessageAdapter.java

   1package eu.siacs.conversations.ui.adapter;
   2
   3import android.Manifest;
   4import android.app.Activity;
   5import android.content.Intent;
   6import android.content.pm.PackageManager;
   7import android.graphics.PorterDuff;
   8import android.graphics.drawable.Drawable;
   9import android.content.res.ColorStateList;
  10import android.graphics.Typeface;
  11import android.net.Uri;
  12import android.os.AsyncTask;
  13import android.os.Build;
  14import android.preference.PreferenceManager;
  15import android.text.Editable;
  16import android.text.Spanned;
  17import android.text.Spannable;
  18import android.text.SpannableString;
  19import android.text.SpannableStringBuilder;
  20import android.text.style.ImageSpan;
  21import android.text.style.ClickableSpan;
  22import android.text.format.DateUtils;
  23import android.text.style.ForegroundColorSpan;
  24import android.text.style.RelativeSizeSpan;
  25import android.text.style.StyleSpan;
  26import android.text.style.URLSpan;
  27import android.util.DisplayMetrics;
  28import android.util.LruCache;
  29import android.view.accessibility.AccessibilityEvent;
  30import android.view.Gravity;
  31import android.view.LayoutInflater;
  32import android.view.MotionEvent;
  33import android.util.Log;
  34import android.view.View;
  35import android.view.ViewGroup;
  36import android.view.WindowManager;
  37import android.widget.ArrayAdapter;
  38import android.widget.ImageView;
  39import android.widget.LinearLayout;
  40import android.widget.ListAdapter;
  41import android.widget.ListView;
  42import android.widget.RelativeLayout;
  43import android.widget.TextView;
  44import android.widget.Toast;
  45import androidx.annotation.AttrRes;
  46import androidx.annotation.ColorInt;
  47import androidx.annotation.DrawableRes;
  48import androidx.annotation.NonNull;
  49import androidx.annotation.Nullable;
  50import androidx.constraintlayout.widget.ConstraintLayout;
  51import androidx.core.app.ActivityCompat;
  52import androidx.core.content.ContextCompat;
  53import androidx.core.content.res.ResourcesCompat;
  54import androidx.core.widget.ImageViewCompat;
  55import androidx.databinding.DataBindingUtil;
  56
  57import com.google.android.material.imageview.ShapeableImageView;
  58import com.google.android.material.shape.CornerFamily;
  59import com.google.android.material.shape.ShapeAppearanceModel;
  60
  61import com.cheogram.android.BobTransfer;
  62import com.cheogram.android.EmojiSearch;
  63import com.cheogram.android.GetThumbnailForCid;
  64import com.cheogram.android.MessageTextActionModeCallback;
  65import com.cheogram.android.SwipeDetector;
  66import com.cheogram.android.Util;
  67import com.cheogram.android.WebxdcPage;
  68import com.cheogram.android.WebxdcUpdate;
  69
  70import com.google.android.material.button.MaterialButton;
  71import com.google.android.material.chip.ChipGroup;
  72import com.google.android.material.color.MaterialColors;
  73import com.google.android.material.dialog.MaterialAlertDialogBuilder;
  74import com.google.common.base.Joiner;
  75import com.google.common.base.Strings;
  76import com.google.common.collect.Collections2;
  77import com.google.common.collect.ImmutableList;
  78import com.google.common.collect.ImmutableSet;
  79
  80import com.lelloman.identicon.view.GithubIdenticonView;
  81
  82import io.ipfs.cid.Cid;
  83
  84import java.io.IOException;
  85import java.net.URI;
  86import java.net.URISyntaxException;
  87import java.security.NoSuchAlgorithmException;
  88import java.util.HashMap;
  89import java.util.List;
  90import java.util.Map;
  91import java.util.Locale;
  92import java.util.function.Function;
  93import java.util.regex.Matcher;
  94import java.util.regex.Pattern;
  95
  96import me.saket.bettermovementmethod.BetterLinkMovementMethod;
  97
  98import net.fellbaum.jemoji.EmojiManager;
  99
 100import de.gultsch.common.Linkify;
 101import eu.siacs.conversations.AppSettings;
 102import eu.siacs.conversations.Config;
 103import eu.siacs.conversations.R;
 104import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
 105import eu.siacs.conversations.databinding.LinkDescriptionBinding;
 106import eu.siacs.conversations.databinding.DialogAddReactionBinding;
 107import eu.siacs.conversations.databinding.ItemMessageDateBubbleBinding;
 108import eu.siacs.conversations.databinding.ItemMessageEndBinding;
 109import eu.siacs.conversations.databinding.ItemMessageRtpSessionBinding;
 110import eu.siacs.conversations.databinding.ItemMessageStartBinding;
 111import eu.siacs.conversations.databinding.ItemMessageStatusBinding;
 112import eu.siacs.conversations.entities.Account;
 113import eu.siacs.conversations.entities.Contact;
 114import eu.siacs.conversations.entities.Conversation;
 115import eu.siacs.conversations.entities.Conversational;
 116import eu.siacs.conversations.entities.DownloadableFile;
 117import eu.siacs.conversations.entities.Message.FileParams;
 118import eu.siacs.conversations.entities.Message;
 119import eu.siacs.conversations.entities.MucOptions;
 120import eu.siacs.conversations.entities.Reaction;
 121import eu.siacs.conversations.entities.Roster;
 122import eu.siacs.conversations.entities.RtpSessionStatus;
 123import eu.siacs.conversations.entities.Transferable;
 124import eu.siacs.conversations.persistance.FileBackend;
 125import eu.siacs.conversations.services.MessageArchiveService;
 126import eu.siacs.conversations.services.NotificationService;
 127import eu.siacs.conversations.services.XmppConnectionService;
 128import eu.siacs.conversations.ui.Activities;
 129import eu.siacs.conversations.ui.BindingAdapters;
 130import eu.siacs.conversations.ui.ConversationFragment;
 131import eu.siacs.conversations.ui.ConversationsActivity;
 132import eu.siacs.conversations.ui.XmppActivity;
 133import eu.siacs.conversations.ui.service.AudioPlayer;
 134import eu.siacs.conversations.ui.text.DividerSpan;
 135import eu.siacs.conversations.ui.text.FixedURLSpan;
 136import eu.siacs.conversations.ui.text.QuoteSpan;
 137import eu.siacs.conversations.ui.util.Attachment;
 138import eu.siacs.conversations.ui.util.AvatarWorkerTask;
 139import eu.siacs.conversations.ui.util.QuoteHelper;
 140import eu.siacs.conversations.ui.util.ShareUtil;
 141import eu.siacs.conversations.ui.util.ViewUtil;
 142import eu.siacs.conversations.utils.CryptoHelper;
 143import eu.siacs.conversations.utils.Emoticons;
 144import eu.siacs.conversations.utils.GeoHelper;
 145import eu.siacs.conversations.utils.MessageUtils;
 146import eu.siacs.conversations.utils.StylingHelper;
 147import eu.siacs.conversations.utils.TimeFrameUtils;
 148import eu.siacs.conversations.utils.UIHelper;
 149import eu.siacs.conversations.xmpp.Jid;
 150import eu.siacs.conversations.xmpp.mam.MamReference;
 151import eu.siacs.conversations.xml.Element;
 152import kotlin.coroutines.Continuation;
 153
 154import java.net.URI;
 155import java.util.Arrays;
 156import java.util.Collection;
 157import java.util.List;
 158import java.util.Locale;
 159import java.util.regex.Matcher;
 160import java.util.regex.Pattern;
 161
 162public class MessageAdapter extends ArrayAdapter<Message> {
 163
 164    public static final String DATE_SEPARATOR_BODY = "DATE_SEPARATOR";
 165    private static final int END = 0;
 166    private static final int START = 1;
 167    private static final int STATUS = 2;
 168    private static final int DATE_SEPARATOR = 3;
 169    private static final int RTP_SESSION = 4;
 170    private final XmppActivity activity;
 171    private final AudioPlayer audioPlayer;
 172    private List<String> highlightedTerm = null;
 173    private final DisplayMetrics metrics;
 174    private ConversationFragment mConversationFragment = null;
 175    private OnContactPictureClicked mOnContactPictureClickedListener;
 176    private OnContactPictureClicked mOnMessageBoxClickedListener;
 177    private OnContactPictureClicked mOnMessageBoxSwipedListener;
 178    private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
 179    private OnInlineImageLongClicked mOnInlineImageLongClickedListener;
 180    private BubbleDesign bubbleDesign = new BubbleDesign(false, false, false, true);
 181    private final boolean mForceNames;
 182    private final Map<String, WebxdcUpdate> lastWebxdcUpdate = new HashMap<>();
 183    private String selectionUuid = null;
 184    private final AppSettings appSettings;
 185
 186    public MessageAdapter(
 187            final XmppActivity activity, final List<Message> messages, final boolean forceNames) {
 188        super(activity, 0, messages);
 189        this.audioPlayer = new AudioPlayer(this);
 190        this.activity = activity;
 191        metrics = getContext().getResources().getDisplayMetrics();
 192        appSettings = new AppSettings(activity);
 193        updatePreferences();
 194        this.mForceNames = forceNames;
 195    }
 196
 197    public MessageAdapter(final XmppActivity activity, final List<Message> messages) {
 198        this(activity, messages, false);
 199    }
 200
 201    private static void resetClickListener(View... views) {
 202        for (View view : views) {
 203            if (view != null) view.setOnClickListener(null);
 204        }
 205    }
 206
 207    public void flagScreenOn() {
 208        activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
 209    }
 210
 211    public void flagScreenOff() {
 212        activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
 213    }
 214
 215    public void setVolumeControl(final int stream) {
 216        activity.setVolumeControlStream(stream);
 217    }
 218
 219    public void setOnContactPictureClicked(OnContactPictureClicked listener) {
 220        this.mOnContactPictureClickedListener = listener;
 221    }
 222
 223    public void setOnMessageBoxClicked(OnContactPictureClicked listener) {
 224        this.mOnMessageBoxClickedListener = listener;
 225    }
 226
 227    public void setOnMessageBoxSwiped(OnContactPictureClicked listener) {
 228        this.mOnMessageBoxSwipedListener = listener;
 229    }
 230
 231    public void setConversationFragment(ConversationFragment frag) {
 232        mConversationFragment = frag;
 233    }
 234
 235    public void quoteText(String text) {
 236        if (mConversationFragment != null) mConversationFragment.quoteText(text);
 237    }
 238
 239    public boolean hasSelection() {
 240        return selectionUuid != null;
 241    }
 242
 243    public Activity getActivity() {
 244        return activity;
 245    }
 246
 247    public void setOnContactPictureLongClicked(OnContactPictureLongClicked listener) {
 248        this.mOnContactPictureLongClickedListener = listener;
 249    }
 250
 251    public void setOnInlineImageLongClicked(OnInlineImageLongClicked listener) {
 252        this.mOnInlineImageLongClickedListener = listener;
 253    }
 254
 255    @Override
 256    public int getViewTypeCount() {
 257        return 5;
 258    }
 259
 260    private static int getItemViewType(final Message message, final boolean alignStart) {
 261        if (message.getType() == Message.TYPE_STATUS) {
 262            if (DATE_SEPARATOR_BODY.equals(message.getBody())) {
 263                return DATE_SEPARATOR;
 264            } else {
 265                return STATUS;
 266            }
 267        } else if (message.getType() == Message.TYPE_RTP_SESSION) {
 268            return RTP_SESSION;
 269        } else if (message.getStatus() <= Message.STATUS_RECEIVED || alignStart) {
 270            return START;
 271        } else {
 272            return END;
 273        }
 274    }
 275
 276    @Override
 277    public int getItemViewType(final int position) {
 278        return getItemViewType(getItem(position), bubbleDesign.alignStart);
 279    }
 280
 281    private void displayStatus(
 282            final BubbleMessageItemViewHolder viewHolder,
 283            final Message message,
 284            final BubbleColor bubbleColor) {
 285        final int status = message.getStatus();
 286        final boolean error;
 287        final Transferable transferable = message.getTransferable();
 288        final boolean multiReceived =
 289                message.getConversation().getMode() == Conversation.MODE_MULTI
 290                        && message.getStatus() <= Message.STATUS_RECEIVED;
 291        final boolean sent = status != Message.STATUS_RECEIVED;
 292        final boolean showUserNickname =
 293                message.getConversation().getMode() == Conversation.MODE_MULTI
 294                        && viewHolder instanceof StartBubbleMessageItemViewHolder;
 295        final String fileSize;
 296        if (message.isFileOrImage()
 297                || transferable != null
 298                || MessageUtils.unInitiatedButKnownSize(message)) {
 299            final FileParams params = message.getFileParams();
 300            fileSize = params.size != null ? UIHelper.filesizeToString(params.size) : null;
 301            if (message.getStatus() == Message.STATUS_SEND_FAILED
 302                    || (transferable != null
 303                            && (transferable.getStatus() == Transferable.STATUS_FAILED
 304                                    || transferable.getStatus()
 305                                            == Transferable.STATUS_CANCELLED))) {
 306                error = true;
 307            } else {
 308                error = message.getStatus() == Message.STATUS_SEND_FAILED;
 309            }
 310        } else {
 311            fileSize = null;
 312            error = message.getStatus() == Message.STATUS_SEND_FAILED;
 313        }
 314
 315        if (sent) {
 316            final @DrawableRes Integer receivedIndicator =
 317                    getMessageStatusAsDrawable(message, status);
 318            if (receivedIndicator == null) {
 319                viewHolder.indicatorReceived().setVisibility(View.INVISIBLE);
 320            } else {
 321                viewHolder.indicatorReceived().setImageResource(receivedIndicator);
 322                if (status == Message.STATUS_SEND_FAILED) {
 323                    setImageTintError(viewHolder.indicatorReceived());
 324                } else {
 325                    setImageTint(viewHolder.indicatorReceived(), bubbleColor);
 326                }
 327                viewHolder.indicatorReceived().setVisibility(View.VISIBLE);
 328            }
 329        } else {
 330            viewHolder.indicatorReceived().setVisibility(View.GONE);
 331        }
 332        final var additionalStatusInfo = getAdditionalStatusInfo(message, status);
 333
 334        if (error && sent) {
 335            viewHolder
 336                    .time()
 337                    .setTextColor(
 338                            MaterialColors.getColor(
 339                                    viewHolder.time(), androidx.appcompat.R.attr.colorError));
 340        } else {
 341            setTextColor(viewHolder.time(), bubbleColor);
 342        }
 343        setTextColor(viewHolder.subject(), bubbleColor);
 344        if (message.getEncryption() == Message.ENCRYPTION_NONE) {
 345            viewHolder.indicatorSecurity().setVisibility(View.GONE);
 346        } else {
 347            boolean verified = false;
 348            if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
 349                final FingerprintStatus fingerprintStatus =
 350                        message.getConversation()
 351                                .getAccount()
 352                                .getAxolotlService()
 353                                .getFingerprintTrust(message.getFingerprint());
 354                if (fingerprintStatus != null && fingerprintStatus.isVerified()) {
 355                    verified = true;
 356                }
 357            }
 358            if (verified) {
 359                viewHolder.indicatorSecurity().setImageResource(R.drawable.ic_verified_user_24dp);
 360            } else {
 361                viewHolder.indicatorSecurity().setImageResource(R.drawable.ic_lock_24dp);
 362            }
 363            if (error && sent) {
 364                setImageTintError(viewHolder.indicatorSecurity());
 365            } else {
 366                setImageTint(viewHolder.indicatorSecurity(), bubbleColor);
 367            }
 368            viewHolder.indicatorSecurity().setVisibility(View.VISIBLE);
 369        }
 370
 371        if (message.edited()) {
 372            viewHolder.indicatorEdit().setVisibility(View.VISIBLE);
 373            if (error && sent) {
 374                setImageTintError(viewHolder.indicatorEdit());
 375            } else {
 376                setImageTint(viewHolder.indicatorEdit(), bubbleColor);
 377            }
 378        } else {
 379            viewHolder.indicatorEdit().setVisibility(View.GONE);
 380        }
 381
 382        final String formattedTime =
 383                UIHelper.readableTimeDifferenceFull(getContext(), message.getTimeSent());
 384        final String bodyLanguage = message.getBodyLanguage();
 385        final ImmutableList.Builder<String> timeInfoBuilder = new ImmutableList.Builder<>();
 386
 387        if (mForceNames || multiReceived || showUserNickname || (message.getTrueCounterpart() != null && message.getContact() != null)) {
 388            final String displayName = UIHelper.getMessageDisplayName(message);
 389            if (displayName != null) {
 390                timeInfoBuilder.add(displayName);
 391            }
 392        }
 393        if (fileSize != null) {
 394            timeInfoBuilder.add(fileSize);
 395        }
 396        if (bodyLanguage != null) {
 397            timeInfoBuilder.add(bodyLanguage.toUpperCase(Locale.US));
 398        }
 399        // for space reasons we display only 'additional status info' (send progress or concrete
 400        // failure reason) or the time
 401        if (additionalStatusInfo != null) {
 402            timeInfoBuilder.add(additionalStatusInfo);
 403        } else {
 404            timeInfoBuilder.add(formattedTime);
 405        }
 406        final var timeInfo = timeInfoBuilder.build();
 407        viewHolder.time().setText(Joiner.on(" · ").join(timeInfo));
 408    }
 409
 410    public static @DrawableRes Integer getMessageStatusAsDrawable(
 411            final Message message, final int status) {
 412        final var transferable = message.getTransferable();
 413        return switch (status) {
 414            case Message.STATUS_WAITING -> R.drawable.ic_more_horiz_24dp;
 415            case Message.STATUS_UNSEND -> transferable == null ? null : R.drawable.ic_upload_24dp;
 416            case Message.STATUS_SEND -> R.drawable.ic_done_24dp;
 417            case Message.STATUS_SEND_RECEIVED, Message.STATUS_SEND_DISPLAYED ->
 418                    R.drawable.ic_done_all_24dp;
 419            case Message.STATUS_SEND_FAILED -> {
 420                final String errorMessage = message.getErrorMessage();
 421                if (Message.ERROR_MESSAGE_CANCELLED.equals(errorMessage)) {
 422                    yield R.drawable.ic_cancel_24dp;
 423                } else {
 424                    yield R.drawable.ic_error_24dp;
 425                }
 426            }
 427            case Message.STATUS_OFFERED -> R.drawable.ic_p2p_24dp;
 428            default -> null;
 429        };
 430    }
 431
 432    @Nullable
 433    private String getAdditionalStatusInfo(final Message message, final int mergedStatus) {
 434        final String additionalStatusInfo;
 435        if (mergedStatus == Message.STATUS_SEND_FAILED) {
 436            final String errorMessage = Strings.nullToEmpty(message.getErrorMessage());
 437            final String[] errorParts = errorMessage.split("\\u001f", 2);
 438            if (errorParts.length == 2 && errorParts[0].equals("file-too-large")) {
 439                additionalStatusInfo = getContext().getString(R.string.file_too_large);
 440            } else {
 441                additionalStatusInfo = null;
 442            }
 443        } else if (mergedStatus == Message.STATUS_UNSEND) {
 444            final var transferable = message.getTransferable();
 445            if (transferable == null) {
 446                return null;
 447            }
 448            return getContext().getString(R.string.sending_file, transferable.getProgress());
 449        } else {
 450            additionalStatusInfo = null;
 451        }
 452        return additionalStatusInfo;
 453    }
 454
 455    private void displayInfoMessage(
 456            BubbleMessageItemViewHolder viewHolder,
 457            CharSequence text,
 458            final BubbleColor bubbleColor) {
 459        viewHolder.downloadButton().setVisibility(View.GONE);
 460        viewHolder.audioPlayer().setVisibility(View.GONE);
 461        viewHolder.image().setVisibility(View.GONE);
 462        viewHolder.messageBody().setTypeface(null, Typeface.ITALIC);
 463        viewHolder.messageBody().setVisibility(View.VISIBLE);
 464        viewHolder.messageBody().setText(text);
 465        viewHolder
 466                .messageBody()
 467                .setTextColor(bubbleToOnSurfaceVariant(viewHolder.messageBody(), bubbleColor));
 468        viewHolder.messageBody().setTextIsSelectable(false);
 469    }
 470
 471    private void displayEmojiMessage(
 472            final BubbleMessageItemViewHolder viewHolder,
 473            final Message message,
 474            final BubbleColor bubbleColor) {
 475        displayTextMessage(viewHolder, message, bubbleColor);
 476        viewHolder.downloadButton().setVisibility(View.GONE);
 477        viewHolder.audioPlayer().setVisibility(View.GONE);
 478        viewHolder.image().setVisibility(View.GONE);
 479        viewHolder.messageBody().setTypeface(null, Typeface.NORMAL);
 480        viewHolder.messageBody().setVisibility(View.VISIBLE);
 481        setTextColor(viewHolder.messageBody(), bubbleColor);
 482        final var body = getSpannableBody(message);
 483        ImageSpan[] imageSpans = body.getSpans(0, body.length(), ImageSpan.class);
 484        float size = imageSpans.length == 1 || Emoticons.isEmoji(body.toString()) ? 5.0f : 2.0f;
 485        body.setSpan(
 486                new RelativeSizeSpan(size), 0, body.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 487        viewHolder.messageBody().setText(body);
 488    }
 489
 490    private void applyQuoteSpan(
 491            final TextView textView,
 492            Editable body,
 493            int start,
 494            int end,
 495            final BubbleColor bubbleColor,
 496            final boolean makeEdits) {
 497        if (makeEdits && start > 1 && !"\n\n".equals(body.subSequence(start - 2, start).toString())) {
 498            body.insert(start++, "\n");
 499            body.setSpan(
 500                    new DividerSpan(false), start - 2, start, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 501            end++;
 502        }
 503        if (makeEdits && end < body.length() - 1 && !"\n\n".equals(body.subSequence(end, end + 2).toString())) {
 504            body.insert(end, "\n");
 505            body.setSpan(
 506                new DividerSpan(false),
 507                end,
 508                end + ("\n".equals(body.subSequence(end + 1, end + 2).toString()) ? 2 : 1),
 509                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
 510            );
 511        }
 512        final DisplayMetrics metrics = getContext().getResources().getDisplayMetrics();
 513        body.setSpan(
 514                new QuoteSpan(bubbleToOnSurfaceVariant(textView, bubbleColor), metrics),
 515                start,
 516                end,
 517                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 518    }
 519
 520    public boolean handleTextQuotes(final TextView textView, final Editable body) {
 521        return handleTextQuotes(textView, body, true);
 522    }
 523
 524    public boolean handleTextQuotes(final TextView textView, final Editable body, final boolean deleteMarkers) {
 525        final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
 526        final BubbleColor bubbleColor = colorfulBackground ? (deleteMarkers ? BubbleColor.SECONDARY : BubbleColor.TERTIARY) : BubbleColor.SURFACE;
 527        return handleTextQuotes(textView, body, bubbleColor, deleteMarkers);
 528    }
 529
 530    /**
 531     * Applies QuoteSpan to group of lines which starts with > or » characters. Appends likebreaks
 532     * and applies DividerSpan to them to show a padding between quote and text.
 533     */
 534    public boolean handleTextQuotes(
 535            final TextView textView,
 536            final Editable body,
 537            final BubbleColor bubbleColor,
 538            final boolean deleteMarkers) {
 539        boolean startsWithQuote = false;
 540        int quoteDepth = 1;
 541        while (QuoteHelper.bodyContainsQuoteStart(body) && quoteDepth <= Config.QUOTE_MAX_DEPTH) {
 542            char previous = '\n';
 543            int lineStart = -1;
 544            int lineTextStart = -1;
 545            int quoteStart = -1;
 546            int skipped = 0;
 547            for (int i = 0; i <= body.length(); i++) {
 548                if (!deleteMarkers && QuoteHelper.isRelativeSizeSpanned(body, i)) {
 549                    skipped++;
 550                    continue;
 551                }
 552                char current = body.length() > i ? body.charAt(i) : '\n';
 553                if (lineStart == -1) {
 554                    if (previous == '\n') {
 555                        if (i < body.length() && QuoteHelper.isPositionQuoteStart(body, i)) {
 556                            // Line start with quote
 557                            lineStart = i;
 558                            if (quoteStart == -1) quoteStart = i - skipped;
 559                            if (i == 0) startsWithQuote = true;
 560                        } else if (quoteStart >= 0) {
 561                            // Line start without quote, apply spans there
 562                            applyQuoteSpan(textView, body, quoteStart, i - 1, bubbleColor, deleteMarkers);
 563                            quoteStart = -1;
 564                        }
 565                    }
 566                } else {
 567                    // Remove extra spaces between > and first character in the line
 568                    // > character will be removed too
 569                    if (current != ' ' && lineTextStart == -1) {
 570                        lineTextStart = i;
 571                    }
 572                    if (current == '\n') {
 573                        if (deleteMarkers) {
 574                            i -= lineTextStart - lineStart;
 575                            body.delete(lineStart, lineTextStart);
 576                            if (i == lineStart) {
 577                                // Avoid empty lines because span over empty line can be hidden
 578                                body.insert(i++, " ");
 579                            }
 580                        } else {
 581                            body.setSpan(new RelativeSizeSpan(i - (lineTextStart - lineStart) == lineStart ? 1 : 0), lineStart, lineTextStart, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE | StylingHelper.XHTML_REMOVE << Spanned.SPAN_USER_SHIFT);
 582                        }
 583                        lineStart = -1;
 584                        lineTextStart = -1;
 585                    }
 586                }
 587                previous = current;
 588                skipped = 0;
 589            }
 590            if (quoteStart >= 0) {
 591                // Apply spans to finishing open quote
 592                applyQuoteSpan(textView, body, quoteStart, body.length(), bubbleColor, deleteMarkers);
 593            }
 594            quoteDepth++;
 595        }
 596        return startsWithQuote;
 597    }
 598
 599    private SpannableStringBuilder getSpannableBody(final Message message) {
 600        Drawable fallbackImg = ResourcesCompat.getDrawable(activity.getResources(), R.drawable.ic_photo_24dp, null);
 601        return message.getSpannableBody(new Thumbnailer(message), fallbackImg);
 602    }
 603
 604    private void displayTextMessage(
 605            final BubbleMessageItemViewHolder viewHolder,
 606            final Message message,
 607            final BubbleColor bubbleColor) {
 608        viewHolder.inReplyToQuote().setVisibility(View.GONE);
 609        viewHolder.downloadButton().setVisibility(View.GONE);
 610        viewHolder.image().setVisibility(View.GONE);
 611        viewHolder.audioPlayer().setVisibility(View.GONE);
 612        viewHolder.messageBody().setVisibility(View.VISIBLE);
 613        setTextColor(viewHolder.messageBody(), bubbleColor);
 614        setTextSize(viewHolder.messageBody(), this.bubbleDesign.largeFont);
 615        setTextSize(viewHolder.inReplyTo(), this.bubbleDesign.largeFont);
 616        setTextSize(viewHolder.inReplyToQuote(), this.bubbleDesign.largeFont);
 617        viewHolder.messageBody().setTypeface(null, Typeface.NORMAL);
 618
 619        final ViewGroup.LayoutParams layoutParams = viewHolder.messageBody().getLayoutParams();
 620        layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT;
 621        viewHolder.messageBody().setLayoutParams(layoutParams);
 622
 623        final ViewGroup.LayoutParams qlayoutParams = viewHolder.inReplyToQuote().getLayoutParams();
 624        qlayoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT;
 625        viewHolder.inReplyToQuote().setLayoutParams(qlayoutParams);
 626
 627        final var rawBody = message.getBody();
 628        if (Strings.isNullOrEmpty(rawBody)) {
 629            viewHolder.messageBody().setText("");
 630            viewHolder.messageBody().setTextIsSelectable(false);
 631            toggleWhisperInfo(viewHolder, message, bubbleColor);
 632            return;
 633        }
 634        viewHolder.messageBody().setTextIsSelectable(true);
 635        final String nick = UIHelper.getMessageDisplayName(message);
 636        SpannableStringBuilder body = getSpannableBody(message);
 637        final var processMarkup = body.getSpans(0, body.length(), Message.PlainTextSpan.class).length > 0;
 638        if (body.length() > Config.MAX_DISPLAY_MESSAGE_CHARS) {
 639            body = new SpannableStringBuilder(body, 0, Config.MAX_DISPLAY_MESSAGE_CHARS);
 640            body.append("");
 641        }
 642        if (processMarkup) StylingHelper.format(body, viewHolder.messageBody().getCurrentTextColor());
 643        Linkify.addLinks(body, message.getConversation().getAccount(), message.getConversation().getJid());
 644        FixedURLSpan.fix(body);
 645        boolean startsWithQuote = processMarkup ? handleTextQuotes(viewHolder.messageBody(), body, bubbleColor, true) : false;
 646        for (final android.text.style.QuoteSpan quote : body.getSpans(0, body.length(), android.text.style.QuoteSpan.class)) {
 647            int start = body.getSpanStart(quote);
 648            int end = body.getSpanEnd(quote);
 649            if (start < 0 || end < 0) continue;
 650
 651            body.removeSpan(quote);
 652            applyQuoteSpan(viewHolder.messageBody(), body, start, end, bubbleColor, true);
 653            if (start == 0) {
 654                if (message.getInReplyTo() == null) {
 655                    startsWithQuote = true;
 656                } else {
 657                    viewHolder.inReplyToQuote().setText(body.subSequence(start, end));
 658                    viewHolder.inReplyToQuote().setVisibility(View.VISIBLE);
 659                    body.delete(start, end);
 660                    while (body.length() > start && body.charAt(start) == '\n') body.delete(start, 1); // Newlines after quote
 661                    continue;
 662                }
 663            }
 664        }
 665        boolean hasMeCommand = body.toString().startsWith(Message.ME_COMMAND);
 666        if (hasMeCommand) {
 667            body.replace(0, Message.ME_COMMAND.length(), String.format("%s ", nick));
 668        }
 669        if (!message.isPrivateMessage()) {
 670            if (hasMeCommand && body.length() > nick.length()) {
 671                body.setSpan(
 672                        new StyleSpan(Typeface.BOLD_ITALIC),
 673                        0,
 674                        nick.length(),
 675                        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 676            }
 677        } else {
 678            String privateMarker;
 679            if (message.getStatus() <= Message.STATUS_RECEIVED) {
 680                privateMarker = activity.getString(R.string.private_message);
 681            } else {
 682                Jid cp = message.getCounterpart();
 683                privateMarker =
 684                        activity.getString(
 685                                R.string.private_message_to,
 686                                Strings.nullToEmpty(cp == null ? null : cp.getResource()));
 687            }
 688            body.insert(0, privateMarker);
 689            int privateMarkerIndex = privateMarker.length();
 690            if (startsWithQuote) {
 691                body.insert(privateMarkerIndex, "\n\n");
 692                body.setSpan(
 693                        new DividerSpan(false),
 694                        privateMarkerIndex,
 695                        privateMarkerIndex + 2,
 696                        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 697            } else {
 698                body.insert(privateMarkerIndex, " ");
 699            }
 700            body.setSpan(
 701                    new ForegroundColorSpan(
 702                            bubbleToOnSurfaceVariant(viewHolder.messageBody(), bubbleColor)),
 703                    0,
 704                    privateMarkerIndex,
 705                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 706            body.setSpan(
 707                    new StyleSpan(Typeface.BOLD),
 708                    0,
 709                    privateMarkerIndex,
 710                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 711            if (hasMeCommand) {
 712                body.setSpan(
 713                        new StyleSpan(Typeface.BOLD_ITALIC),
 714                        privateMarkerIndex + 1,
 715                        privateMarkerIndex + 1 + nick.length(),
 716                        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 717            }
 718        }
 719        if (message.getConversation().getMode() == Conversation.MODE_MULTI
 720                && message.getStatus() == Message.STATUS_RECEIVED) {
 721            if (message.getConversation() instanceof Conversation conversation) {
 722                Pattern pattern =
 723                        NotificationService.generateNickHighlightPattern(
 724                                conversation.getMucOptions().getActualNick());
 725                Matcher matcher = pattern.matcher(body);
 726                while (matcher.find()) {
 727                    body.setSpan(
 728                            new StyleSpan(Typeface.BOLD),
 729                            matcher.start(),
 730                            matcher.end(),
 731                            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 732                }
 733            }
 734        }
 735
 736        for (final var emoji : EmojiManager.extractEmojisInOrderWithIndex(body.toString())) {
 737            var end = emoji.getCharIndex() + emoji.getEmoji().getEmoji().length();
 738            if (body.length() > end && body.charAt(end) == '\uFE0F') end++;
 739            body.setSpan(
 740                    new RelativeSizeSpan(1.2f),
 741                    emoji.getCharIndex(),
 742                    end,
 743                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 744        }
 745        // Make custom emoji bigger too, to match emoji
 746        for (final var span : body.getSpans(0, body.length(), com.cheogram.android.InlineImageSpan.class)) {
 747            body.setSpan(
 748                    new RelativeSizeSpan(1.2f),
 749                    body.getSpanStart(span),
 750                    body.getSpanEnd(span),
 751                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 752        }
 753
 754        if (highlightedTerm != null) {
 755            StylingHelper.highlight(viewHolder.messageBody(), body, highlightedTerm);
 756        }
 757
 758        viewHolder.messageBody().setAutoLinkMask(0);
 759        viewHolder.messageBody().setText(body);
 760        if (body.length() <= 0) viewHolder.messageBody().setVisibility(View.GONE);
 761        BetterLinkMovementMethod method = new BetterLinkMovementMethod() {
 762            @Override
 763            protected void dispatchUrlLongClick(TextView tv, ClickableSpan span) {
 764                if (span instanceof URLSpan || mOnInlineImageLongClickedListener == null) {
 765                    tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
 766                    super.dispatchUrlLongClick(tv, span);
 767                    return;
 768                }
 769
 770                Spannable body = (Spannable) tv.getText();
 771                ImageSpan[] imageSpans = body.getSpans(body.getSpanStart(span), body.getSpanEnd(span), ImageSpan.class);
 772                if (imageSpans.length > 0) {
 773                    Uri uri = Uri.parse(imageSpans[0].getSource());
 774                    Cid cid = BobTransfer.cid(uri);
 775                    if (cid == null) return;
 776                    if (mOnInlineImageLongClickedListener.onInlineImageLongClicked(cid)) {
 777                        tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
 778                    }
 779                }
 780            }
 781        };
 782        method.setOnLinkLongClickListener((tv, url) -> {
 783            tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
 784            ShareUtil.copyLinkToClipboard(activity, url);
 785            return true;
 786        });
 787        viewHolder.messageBody().setMovementMethod(method);
 788    }
 789
 790    private void displayDownloadableMessage(
 791            final BubbleMessageItemViewHolder viewHolder,
 792            final Message message,
 793            String text,
 794            final BubbleColor bubbleColor) {
 795        displayTextMessage(viewHolder, message, bubbleColor);
 796        viewHolder.image().setVisibility(View.GONE);
 797        List<Element> thumbs = message.getFileParams() != null ? message.getFileParams().getThumbnails() : null;
 798        if (thumbs != null && !thumbs.isEmpty()) {
 799            for (Element thumb : thumbs) {
 800                Uri uri = Uri.parse(thumb.getAttribute("uri"));
 801                if (uri.getScheme().equals("data")) {
 802                    String[] parts = uri.getSchemeSpecificPart().split(",", 2);
 803                    parts = parts[0].split(";");
 804                    if (!parts[0].equals("image/blurhash") && !parts[0].equals("image/thumbhash") && !parts[0].equals("image/jpeg") && !parts[0].equals("image/png") && !parts[0].equals("image/webp") && !parts[0].equals("image/gif")) continue;
 805                } else if (uri.getScheme().equals("cid")) {
 806                    Cid cid = BobTransfer.cid(uri);
 807                    if (cid == null) continue;
 808                    DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
 809                    if (f == null || !f.canRead()) {
 810                        if (!message.trusted() && !message.getConversation().canInferPresence()) continue;
 811
 812                        try {
 813                            new BobTransfer(BobTransfer.uri(cid), message.getConversation().getAccount(), message.getCounterpart(), activity.xmppConnectionService).start();
 814                        } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
 815                        continue;
 816                    }
 817                } else {
 818                    continue;
 819                }
 820
 821                int width = message.getFileParams().width;
 822                if (width < 1 && thumb.getAttribute("width") != null) width = Integer.parseInt(thumb.getAttribute("width"));
 823                if (width < 1) width = 1920;
 824
 825                int height = message.getFileParams().height;
 826                if (height < 1 && thumb.getAttribute("height") != null) height = Integer.parseInt(thumb.getAttribute("height"));
 827                if (height < 1) height = 1080;
 828
 829                viewHolder.image().setVisibility(View.VISIBLE);
 830                imagePreviewLayout(width, height, viewHolder.image(), message.getInReplyTo() != null, true, viewHolder);
 831                activity.loadBitmap(message, viewHolder.image());
 832                viewHolder.image().setOnClickListener(v -> ConversationFragment.downloadFile(activity, message));
 833
 834                break;
 835            }
 836        }
 837        viewHolder.audioPlayer().setVisibility(View.GONE);
 838        viewHolder.downloadButton().setVisibility(View.VISIBLE);
 839        viewHolder.downloadButton().setText(text);
 840        final var attachment = Attachment.of(message);
 841        final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
 842        viewHolder.downloadButton().setIconResource(imageResource);
 843        viewHolder
 844                .downloadButton()
 845                .setOnClickListener(v -> ConversationFragment.downloadFile(activity, message));
 846    }
 847
 848    private void displayWebxdcMessage(BubbleMessageItemViewHolder viewHolder, final Message message, final BubbleColor bubbleColor) {
 849        Cid webxdcCid = message.getFileParams().getCids().get(0);
 850        WebxdcPage webxdc = new WebxdcPage(activity, webxdcCid, message);
 851        displayTextMessage(viewHolder, message, bubbleColor);
 852        viewHolder.image().setVisibility(View.GONE);
 853        viewHolder.audioPlayer().setVisibility(View.GONE);
 854        viewHolder.downloadButton().setVisibility(View.VISIBLE);
 855        viewHolder.downloadButton().setIconResource(0);
 856        viewHolder.downloadButton().setText("Open " + webxdc.getName());
 857        viewHolder.downloadButton().setOnClickListener(v -> {
 858            Conversation conversation = (Conversation) message.getConversation();
 859            if (!conversation.switchToSession("webxdc\0" + message.getUuid())) {
 860                conversation.startWebxdc(webxdc);
 861            }
 862        });
 863        viewHolder.image().setOnClickListener(v -> {
 864            Conversation conversation = (Conversation) message.getConversation();
 865            if (!conversation.switchToSession("webxdc\0" + message.getUuid())) {
 866                conversation.startWebxdc(webxdc);
 867            }
 868        });
 869
 870        final WebxdcUpdate lastUpdate;
 871        synchronized(lastWebxdcUpdate) { lastUpdate = lastWebxdcUpdate.get(message.getUuid()); }
 872        if (lastUpdate == null) {
 873            new Thread(() -> {
 874                final WebxdcUpdate update = activity.xmppConnectionService.findLastWebxdcUpdate(message);
 875                if (update != null) {
 876                    synchronized(lastWebxdcUpdate) { lastWebxdcUpdate.put(message.getUuid(), update); }
 877                    activity.xmppConnectionService.updateConversationUi();
 878                }
 879            }).start();
 880        } else {
 881            if (lastUpdate != null && (lastUpdate.getSummary() != null || lastUpdate.getDocument() != null)) {
 882                viewHolder.messageBody().setVisibility(View.VISIBLE);
 883                viewHolder.messageBody().setText(
 884                    (lastUpdate.getDocument() == null ? "" : lastUpdate.getDocument() + "\n") +
 885                    (lastUpdate.getSummary() == null ? "" : lastUpdate.getSummary())
 886                );
 887            }
 888        }
 889
 890        final LruCache<String, Drawable> cache = activity.xmppConnectionService.getDrawableCache();
 891        final Drawable d = cache.get("webxdc:icon:" + webxdcCid);
 892        if (d == null) {
 893            new Thread(() -> {
 894                Drawable icon = webxdc.getIcon();
 895                if (icon != null) {
 896                    cache.put("webxdc:icon:" + webxdcCid, icon);
 897                    activity.xmppConnectionService.updateConversationUi();
 898                }
 899            }).start();
 900        } else {
 901            viewHolder.image().setVisibility(View.VISIBLE);
 902            viewHolder.image().setImageDrawable(d);
 903            imagePreviewLayout(d.getIntrinsicWidth(), d.getIntrinsicHeight(), viewHolder.image(), message.getInReplyTo() != null, true, viewHolder);
 904        }
 905    }
 906
 907    private void displayOpenableMessage(
 908            final BubbleMessageItemViewHolder viewHolder,
 909            final Message message,
 910            final BubbleColor bubbleColor) {
 911        displayTextMessage(viewHolder, message, bubbleColor);
 912        viewHolder.image().setVisibility(View.GONE);
 913        viewHolder.audioPlayer().setVisibility(View.GONE);
 914        viewHolder.downloadButton().setVisibility(View.VISIBLE);
 915        viewHolder
 916                .downloadButton()
 917                .setText(
 918                        activity.getString(
 919                                R.string.open_x_file,
 920                                UIHelper.getFileDescriptionString(activity, message)));
 921        final var attachment = Attachment.of(message);
 922        final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
 923        viewHolder.downloadButton().setIconResource(imageResource);
 924        viewHolder.downloadButton().setOnClickListener(v -> openDownloadable(message));
 925    }
 926
 927    private void displayURIMessage(
 928            BubbleMessageItemViewHolder viewHolder, final Message message, final BubbleColor bubbleColor) {
 929        displayTextMessage(viewHolder, message, bubbleColor);
 930        viewHolder.messageBody().setVisibility(View.GONE);
 931        viewHolder.image().setVisibility(View.GONE);
 932        viewHolder.audioPlayer().setVisibility(View.GONE);
 933        viewHolder.downloadButton().setVisibility(View.VISIBLE);
 934        final var uri = message.wholeIsKnownURI();
 935        if ("bitcoin".equals(uri.getScheme())) {
 936            final var amount = uri.getQueryParameter("amount");
 937            final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
 938            viewHolder.downloadButton().setIconResource(R.drawable.bitcoin_24dp);
 939            viewHolder.downloadButton().setText("Send " + formattedAmount + "Bitcoin");
 940        } else if ("bitcoincash".equals(uri.getScheme())) {
 941            final var amount = uri.getQueryParameter("amount");
 942            final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
 943            viewHolder.downloadButton().setIconResource(R.drawable.bitcoin_cash_24dp);
 944            viewHolder.downloadButton().setText("Send " + formattedAmount + "Bitcoin Cash");
 945        } else if ("ethereum".equals(uri.getScheme())) {
 946            final var amount = uri.getQueryParameter("value");
 947            final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
 948            viewHolder.downloadButton().setIconResource(R.drawable.eth_24dp);
 949            viewHolder.downloadButton().setText("Send " + formattedAmount + "via Ethereum");
 950        } else if ("monero".equals(uri.getScheme())) {
 951            final var amount = uri.getQueryParameter("tx_amount");
 952            final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
 953            viewHolder.downloadButton().setIconResource(R.drawable.monero_24dp);
 954            viewHolder.downloadButton().setText("Send " + formattedAmount + "Monero");
 955        } else if ("wownero".equals(uri.getScheme())) {
 956            final var amount = uri.getQueryParameter("tx_amount");
 957            final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
 958            viewHolder.downloadButton().setIconResource(R.drawable.wownero_24dp);
 959            viewHolder.downloadButton().setText("Send " + formattedAmount + "Wownero");
 960        }
 961        viewHolder.downloadButton().setOnClickListener(v -> new FixedURLSpan(message.getRawBody()).onClick(v));
 962    }
 963
 964    private void displayLocationMessage(
 965            final BubbleMessageItemViewHolder viewHolder,
 966            final Message message,
 967            final BubbleColor bubbleColor) {
 968        displayTextMessage(viewHolder, message, bubbleColor);
 969        viewHolder.image().setVisibility(View.GONE);
 970        viewHolder.audioPlayer().setVisibility(View.GONE);
 971        viewHolder.downloadButton().setVisibility(View.VISIBLE);
 972        viewHolder.downloadButton().setText(R.string.show_location);
 973        final var attachment = Attachment.of(message);
 974        final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
 975        viewHolder.downloadButton().setIconResource(imageResource);
 976        viewHolder.downloadButton().setOnClickListener(v -> showLocation(message));
 977    }
 978
 979    private void displayAudioMessage(
 980            final BubbleMessageItemViewHolder viewHolder,
 981            Message message,
 982            final BubbleColor bubbleColor) {
 983        displayTextMessage(viewHolder, message, bubbleColor);
 984        viewHolder.image().setVisibility(View.GONE);
 985        viewHolder.downloadButton().setVisibility(View.GONE);
 986        final RelativeLayout audioPlayer = viewHolder.audioPlayer();
 987        audioPlayer.setVisibility(View.VISIBLE);
 988        AudioPlayer.ViewHolder.get(audioPlayer).setBubbleColor(bubbleColor);
 989        this.audioPlayer.init(audioPlayer, message);
 990    }
 991
 992    private void displayMediaPreviewMessage(
 993            final BubbleMessageItemViewHolder viewHolder,
 994            final Message message,
 995            final BubbleColor bubbleColor) {
 996        displayTextMessage(viewHolder, message, bubbleColor);
 997        viewHolder.downloadButton().setVisibility(View.GONE);
 998        viewHolder.audioPlayer().setVisibility(View.GONE);
 999        viewHolder.image().setVisibility(View.VISIBLE);
1000        final FileParams params = message.getFileParams();
1001        imagePreviewLayout(params.width, params.height, viewHolder.image(), message.getInReplyTo() != null, viewHolder.messageBody().getVisibility() != View.GONE, viewHolder);
1002        activity.loadBitmap(message, viewHolder.image());
1003        viewHolder.image().setOnClickListener(v -> openDownloadable(message));
1004    }
1005
1006    private void imagePreviewLayout(int w, int h, ShapeableImageView image, boolean otherAbove, boolean otherBelow, BubbleMessageItemViewHolder viewHolder) {
1007        final float target = activity.getResources().getDimension(R.dimen.image_preview_width);
1008        final int scaledW;
1009        final int scaledH;
1010        if (Math.max(h, w) * metrics.density <= target) {
1011            scaledW = (int) (w * metrics.density);
1012            scaledH = (int) (h * metrics.density);
1013        } else if (Math.max(h, w) <= target) {
1014            scaledW = w;
1015            scaledH = h;
1016        } else if (w <= h) {
1017            scaledW = (int) (w / ((double) h / target));
1018            scaledH = (int) target;
1019        } else {
1020            scaledW = (int) target;
1021            scaledH = (int) (h / ((double) w / target));
1022        }
1023        final var bodyWidth = Math.max(viewHolder.messageBody().getWidth(), viewHolder.downloadButton().getWidth() + (20 * metrics.density));
1024        var targetImageWidth = 200 * metrics.density;
1025        if (!otherBelow) targetImageWidth = 110 * metrics.density;
1026        if (bodyWidth > 0 && bodyWidth < targetImageWidth) targetImageWidth = bodyWidth;
1027        final var small = scaledW < targetImageWidth;
1028        final LinearLayout.LayoutParams layoutParams =
1029                new LinearLayout.LayoutParams(scaledW, scaledH);
1030        image.setLayoutParams(layoutParams);
1031
1032        final var bubbleRadius = activity.getResources().getDimension(R.dimen.bubble_radius);
1033        var shape = new ShapeAppearanceModel.Builder();
1034        if (!otherAbove) {
1035            shape = shape.setTopRightCorner(CornerFamily.ROUNDED, bubbleRadius);
1036            if (viewHolder instanceof EndBubbleMessageItemViewHolder) {
1037                shape = shape.setTopLeftCorner(CornerFamily.ROUNDED, bubbleRadius);
1038            }
1039        }
1040        if (small) {
1041            final var imageRadius = activity.getResources().getDimension(R.dimen.image_radius);
1042            shape = shape.setAllCorners(CornerFamily.ROUNDED, imageRadius);
1043            image.setPadding(0, (int)(8 * metrics.density), 0, 0);
1044        } else {
1045            image.setPadding(0, 0, 0, 0);
1046        }
1047        image.setShapeAppearanceModel(shape.build());
1048
1049        if (!small) {
1050            final ViewGroup.LayoutParams blayoutParams = viewHolder.messageBody().getLayoutParams();
1051            blayoutParams.width = (int) (scaledW - (22 * metrics.density));
1052            viewHolder.messageBody().setLayoutParams(blayoutParams);
1053
1054            final ViewGroup.LayoutParams qlayoutParams = viewHolder.inReplyToQuote().getLayoutParams();
1055            qlayoutParams.width = (int) (scaledW - (22 * metrics.density));
1056            viewHolder.messageBody().setLayoutParams(qlayoutParams);
1057        }
1058    }
1059
1060    private void toggleWhisperInfo(
1061            final BubbleMessageItemViewHolder viewHolder,
1062            final Message message,
1063            final BubbleColor bubbleColor) {
1064        if (message.isPrivateMessage()) {
1065            final String privateMarker;
1066            if (message.getStatus() <= Message.STATUS_RECEIVED) {
1067                privateMarker = activity.getString(R.string.private_message);
1068            } else {
1069                Jid cp = message.getCounterpart();
1070                privateMarker =
1071                        activity.getString(
1072                                R.string.private_message_to,
1073                                Strings.nullToEmpty(cp == null ? null : cp.getResource()));
1074            }
1075            final SpannableString body = new SpannableString(privateMarker);
1076            body.setSpan(
1077                    new ForegroundColorSpan(
1078                            bubbleToOnSurfaceVariant(viewHolder.messageBody(), bubbleColor)),
1079                    0,
1080                    privateMarker.length(),
1081                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1082            body.setSpan(
1083                    new StyleSpan(Typeface.BOLD),
1084                    0,
1085                    privateMarker.length(),
1086                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1087            viewHolder.messageBody().setText(body);
1088            viewHolder.messageBody().setTypeface(null, Typeface.NORMAL);
1089            viewHolder.messageBody().setVisibility(View.VISIBLE);
1090        } else {
1091            viewHolder.messageBody().setVisibility(View.GONE);
1092        }
1093    }
1094
1095    private void loadMoreMessages(final Conversation conversation) {
1096        conversation.setLastClearHistory(0, null);
1097        activity.xmppConnectionService.updateConversation(conversation);
1098        conversation.setHasMessagesLeftOnServer(true);
1099        conversation.setFirstMamReference(null);
1100        long timestamp = conversation.getLastMessageTransmitted().getTimestamp();
1101        if (timestamp == 0) {
1102            timestamp = System.currentTimeMillis();
1103        }
1104        conversation.messagesLoaded.set(true);
1105        MessageArchiveService.Query query =
1106                activity.xmppConnectionService
1107                        .getMessageArchiveService()
1108                        .query(conversation, new MamReference(0), timestamp, false);
1109        if (query != null) {
1110            Toast.makeText(activity, R.string.fetching_history_from_server, Toast.LENGTH_LONG)
1111                    .show();
1112        } else {
1113            Toast.makeText(
1114                            activity,
1115                            R.string.not_fetching_history_retention_period,
1116                            Toast.LENGTH_SHORT)
1117                    .show();
1118        }
1119    }
1120
1121    private MessageItemViewHolder getViewHolder(
1122            final View view, final @NonNull ViewGroup parent, final int type) {
1123        if (view != null && view.getTag() instanceof MessageItemViewHolder messageItemViewHolder) {
1124            return messageItemViewHolder;
1125        } else {
1126            final MessageItemViewHolder viewHolder =
1127                    switch (type) {
1128                        case RTP_SESSION ->
1129                                new RtpSessionMessageItemViewHolder(
1130                                        DataBindingUtil.inflate(
1131                                                LayoutInflater.from(parent.getContext()),
1132                                                R.layout.item_message_rtp_session,
1133                                                parent,
1134                                                false));
1135                        case DATE_SEPARATOR ->
1136                                new DateSeperatorMessageItemViewHolder(
1137                                        DataBindingUtil.inflate(
1138                                                LayoutInflater.from(parent.getContext()),
1139                                                R.layout.item_message_date_bubble,
1140                                                parent,
1141                                                false));
1142                        case STATUS ->
1143                                new StatusMessageItemViewHolder(
1144                                        DataBindingUtil.inflate(
1145                                                LayoutInflater.from(parent.getContext()),
1146                                                R.layout.item_message_status,
1147                                                parent,
1148                                                false));
1149                        case END ->
1150                                new EndBubbleMessageItemViewHolder(
1151                                        DataBindingUtil.inflate(
1152                                                LayoutInflater.from(parent.getContext()),
1153                                                R.layout.item_message_end,
1154                                                parent,
1155                                                false));
1156                        case START ->
1157                                new StartBubbleMessageItemViewHolder(
1158                                        DataBindingUtil.inflate(
1159                                                LayoutInflater.from(parent.getContext()),
1160                                                R.layout.item_message_start,
1161                                                parent,
1162                                                false));
1163                        default -> throw new AssertionError("Unable to create ViewHolder for type");
1164                    };
1165            viewHolder.itemView.setTag(viewHolder);
1166            return viewHolder;
1167        }
1168    }
1169
1170    @NonNull
1171    @Override
1172    public View getView(final int position, final View view, final @NonNull ViewGroup parent) {
1173        final Message message = getItem(position);
1174        final int type = getItemViewType(message, bubbleDesign.alignStart);
1175        final MessageItemViewHolder viewHolder = getViewHolder(view, parent, type);
1176
1177        if (type == DATE_SEPARATOR
1178                && viewHolder instanceof DateSeperatorMessageItemViewHolder messageItemViewHolder) {
1179            return render(message, messageItemViewHolder);
1180        }
1181
1182        if (type == RTP_SESSION
1183                && viewHolder instanceof RtpSessionMessageItemViewHolder messageItemViewHolder) {
1184            return render(message, messageItemViewHolder);
1185        }
1186
1187        if (type == STATUS
1188                && viewHolder instanceof StatusMessageItemViewHolder messageItemViewHolder) {
1189            return render(message, messageItemViewHolder);
1190        }
1191
1192        if ((type == END || type == START)
1193                && viewHolder instanceof BubbleMessageItemViewHolder messageItemViewHolder) {
1194            return render(position, message, messageItemViewHolder);
1195        }
1196
1197        throw new AssertionError();
1198    }
1199
1200    private View render(
1201            final int position,
1202            final Message message,
1203            final BubbleMessageItemViewHolder viewHolder) {
1204        final boolean omemoEncryption = message.getEncryption() == Message.ENCRYPTION_AXOLOTL;
1205        final boolean isInValidSession =
1206                message.isValidInSession() && (!omemoEncryption || message.isTrusted());
1207        final Conversational conversation = message.getConversation();
1208        final Account account = conversation.getAccount();
1209        final List<Element> commands = message.getCommands();
1210
1211        viewHolder.linkDescriptions().setOnItemClickListener((adapter, v, pos, id) -> {
1212            final var desc = (Element) adapter.getItemAtPosition(pos);
1213            var url = desc.findChildContent("url", "https://ogp.me/ns#");
1214            // should we prefer about? Maybe, it's the real original link, but it's not what we show the user
1215            if (url == null || url.length() < 1) url = desc.getAttribute("{http://www.w3.org/1999/02/22-rdf-syntax-ns#}about");
1216            if (url == null || url.length() < 1) return;
1217            new FixedURLSpan(url).onClick(v);
1218        });
1219
1220        if (viewHolder.messageBody() != null) {
1221            viewHolder.messageBody().setCustomSelectionActionModeCallback(new MessageTextActionModeCallback(this, viewHolder.messageBody()));
1222        }
1223
1224        if (viewHolder.time() != null) {
1225            if (message.isAttention()) {
1226                viewHolder.time().setTypeface(null, Typeface.BOLD);
1227            } else {
1228                viewHolder.time().setTypeface(null, Typeface.NORMAL);
1229            }
1230        }
1231
1232        final var black = MaterialColors.getColor(viewHolder.root(), com.google.android.material.R.attr.colorSecondaryContainer) == viewHolder.root().getContext().getColor(android.R.color.black);
1233        final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
1234        final boolean received = message.getStatus() == Message.STATUS_RECEIVED;
1235        final BubbleColor bubbleColor;
1236        if (received) {
1237            if (isInValidSession) {
1238                bubbleColor = colorfulBackground  || black ? BubbleColor.SECONDARY : BubbleColor.SURFACE;
1239            } else {
1240                bubbleColor = BubbleColor.WARNING;
1241            }
1242        } else {
1243            if (!colorfulBackground && black) {
1244                bubbleColor = BubbleColor.SECONDARY;
1245            } else {
1246                bubbleColor = colorfulBackground ? BubbleColor.TERTIARY : BubbleColor.SURFACE_HIGH;
1247            }
1248        }
1249
1250        if (viewHolder.threadIdenticon() != null) {
1251            viewHolder.threadIdenticon().setVisibility(View.GONE);
1252            final Element thread = message.getThread();
1253            if (thread != null) {
1254                final String threadId = thread.getContent();
1255                if (threadId != null) {
1256                    final var roles = MaterialColors.getColorRoles(activity, UIHelper.getColorForName(threadId));
1257                    viewHolder.threadIdenticon().setVisibility(View.VISIBLE);
1258                    viewHolder.threadIdenticon().setColor(roles.getAccent());
1259                    viewHolder.threadIdenticon().setHash(UIHelper.identiconHash(threadId));
1260                }
1261            }
1262        }
1263
1264        final var mergeIntoTop = mergeIntoTop(position, message);
1265        final var mergeIntoBottom = mergeIntoBottom(position, message);
1266        final var showAvatar =
1267                bubbleDesign.showAvatars
1268                        || (viewHolder instanceof StartBubbleMessageItemViewHolder
1269                                && message.getConversation().getMode() == Conversation.MODE_MULTI);
1270        setBubblePadding(viewHolder.root(), mergeIntoTop, mergeIntoBottom);
1271        if (showAvatar) {
1272            final var requiresAvatar =
1273                    viewHolder instanceof StartBubbleMessageItemViewHolder
1274                            ? !mergeIntoTop
1275                            : !mergeIntoBottom;
1276            setRequiresAvatar(viewHolder, requiresAvatar);
1277            AvatarWorkerTask.loadAvatar(message, viewHolder.contactPicture(), R.dimen.avatar);
1278        } else {
1279            viewHolder.contactPicture().setVisibility(View.GONE);
1280        }
1281        setAvatarDistance(viewHolder.messageBox(), viewHolder.getClass(), showAvatar);
1282        //viewHolder.messageBox().setClipToOutline(true); remove to show tails
1283
1284        resetClickListener(viewHolder.messageBox(), viewHolder.messageBody());
1285
1286        viewHolder.messageBox().setOnClickListener(v -> {
1287            if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1288                MessageAdapter.this.mOnMessageBoxClickedListener
1289                        .onContactPictureClicked(message);
1290            }
1291        });
1292        SwipeDetector swipeDetector = new SwipeDetector((action) -> {
1293            if (action == SwipeDetector.Action.LR && MessageAdapter.this.mOnMessageBoxSwipedListener != null) {
1294                MessageAdapter.this.mOnMessageBoxSwipedListener.onContactPictureClicked(message);
1295            }
1296        });
1297        viewHolder.messageBox().setOnTouchListener(swipeDetector);
1298        viewHolder.image().setOnTouchListener(swipeDetector);
1299        viewHolder.time().setOnTouchListener(swipeDetector);
1300
1301        // Treat touch-up as click so we don't have to touch twice
1302        // (touch twice is because it's waiting to see if you double-touch for text selection)
1303        viewHolder.messageBody().setOnTouchListener((v, event) -> {
1304            if (event.getAction() == MotionEvent.ACTION_UP) {
1305                if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1306                    MessageAdapter.this.mOnMessageBoxClickedListener
1307                        .onContactPictureClicked(message);
1308                }
1309            }
1310
1311            swipeDetector.onTouch(v, event);
1312
1313            return false;
1314        });
1315        viewHolder.messageBody().setOnClickListener(v -> {
1316            if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1317                MessageAdapter.this.mOnMessageBoxClickedListener
1318                        .onContactPictureClicked(message);
1319            }
1320        });
1321        viewHolder.messageBody().setAccessibilityDelegate(null);
1322
1323        viewHolder
1324                .contactPicture()
1325                .setOnClickListener(
1326                        v -> {
1327                            if (MessageAdapter.this.mOnContactPictureClickedListener != null) {
1328                                MessageAdapter.this.mOnContactPictureClickedListener
1329                                        .onContactPictureClicked(message);
1330                            }
1331                        });
1332        viewHolder
1333                .contactPicture()
1334                .setOnLongClickListener(
1335                        v -> {
1336                            if (MessageAdapter.this.mOnContactPictureLongClickedListener != null) {
1337                                MessageAdapter.this.mOnContactPictureLongClickedListener
1338                                        .onContactPictureLongClicked(v, message);
1339                                return true;
1340                            } else {
1341                                return false;
1342                            }
1343                        });
1344
1345        boolean footerWrap = false;
1346        final Transferable transferable = message.getTransferable();
1347        final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
1348
1349        final boolean muted = message.getStatus() == Message.STATUS_RECEIVED && conversation.getMode() == Conversation.MODE_MULTI && activity.xmppConnectionService.isMucUserMuted(new MucOptions.User(null, conversation.getJid(), message.getOccupantId(), null, null));
1350        if (muted) {
1351            // Muted MUC participant
1352            displayInfoMessage(viewHolder, "Muted", bubbleColor);
1353        } else if (unInitiatedButKnownSize || message.isDeleted() || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING)) {
1354            if (unInitiatedButKnownSize || (message.isDeleted() && message.getModerated() == null) || transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER) {
1355                displayDownloadableMessage(viewHolder, message, activity.getString(R.string.download_x_file, UIHelper.getFileDescriptionString(activity, message)), bubbleColor);
1356            } else if (transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER_CHECK_FILESIZE) {
1357                displayDownloadableMessage(viewHolder, message, activity.getString(R.string.check_x_filesize, UIHelper.getFileDescriptionString(activity, message)), bubbleColor);
1358            } else {
1359                displayInfoMessage(viewHolder, UIHelper.getMessagePreview(activity.xmppConnectionService, message).first, bubbleColor);
1360            }
1361        } else if (message.isFileOrImage()
1362                && message.getEncryption() != Message.ENCRYPTION_PGP
1363                && message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED) {
1364            if (message.getFileParams().width > 0 && message.getFileParams().height > 0) {
1365                displayMediaPreviewMessage(viewHolder, message, bubbleColor);
1366            } else if (message.getFileParams().runtime > 0) {
1367                displayAudioMessage(viewHolder, message, bubbleColor);
1368            } else if ("application/webxdc+zip".equals(message.getFileParams().getMediaType()) && message.getConversation() instanceof Conversation && message.getThread() != null && !message.getFileParams().getCids().isEmpty()) {
1369                displayWebxdcMessage(viewHolder, message, bubbleColor);
1370            } else {
1371                displayOpenableMessage(viewHolder, message, bubbleColor);
1372            }
1373        } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
1374            if (account.isPgpDecryptionServiceConnected()) {
1375                if (conversation instanceof Conversation
1376                        && !account.hasPendingPgpIntent((Conversation) conversation)) {
1377                    displayInfoMessage(
1378                            viewHolder,
1379                            activity.getString(R.string.message_decrypting),
1380                            bubbleColor);
1381                } else {
1382                    displayInfoMessage(
1383                            viewHolder, activity.getString(R.string.pgp_message), bubbleColor);
1384                }
1385            } else {
1386                displayInfoMessage(
1387                        viewHolder, activity.getString(R.string.install_openkeychain), bubbleColor);
1388                viewHolder.messageBox().setOnClickListener(this::promptOpenKeychainInstall);
1389                viewHolder.messageBody().setOnClickListener(this::promptOpenKeychainInstall);
1390            }
1391        } else if (message.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) {
1392            displayInfoMessage(
1393                    viewHolder, activity.getString(R.string.decryption_failed), bubbleColor);
1394        } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE) {
1395            displayInfoMessage(
1396                    viewHolder,
1397                    activity.getString(R.string.not_encrypted_for_this_device),
1398                    bubbleColor);
1399        } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_FAILED) {
1400            displayInfoMessage(
1401                    viewHolder, activity.getString(R.string.omemo_decryption_failed), bubbleColor);
1402        } else {
1403            if (message.wholeIsKnownURI() != null) {
1404                displayURIMessage(viewHolder, message, bubbleColor);
1405            } else if (message.isGeoUri()) {
1406                displayLocationMessage(viewHolder, message, bubbleColor);
1407            } else if (message.treatAsDownloadable()) {
1408                try {
1409                    final URI uri = message.getOob();
1410                    displayDownloadableMessage(viewHolder,
1411                            message,
1412                            activity.getString(
1413                                    R.string.check_x_filesize_on_host,
1414                                    UIHelper.getFileDescriptionString(activity, message),
1415                                    uri.getHost()),
1416                            bubbleColor);
1417                } catch (Exception e) {
1418                    displayDownloadableMessage(
1419                            viewHolder,
1420                            message,
1421                            activity.getString(
1422                                    R.string.check_x_filesize,
1423                                    UIHelper.getFileDescriptionString(activity, message)),
1424                            bubbleColor);
1425                }
1426            } else if (message.bodyIsOnlyEmojis() && message.getType() != Message.TYPE_PRIVATE) {
1427                displayEmojiMessage(viewHolder, message, bubbleColor);
1428            } else {
1429                displayTextMessage(viewHolder, message, bubbleColor);
1430            }
1431        }
1432
1433        if (!black && viewHolder.image().getLayoutParams().width > metrics.density * 110) {
1434            footerWrap = true;
1435        }
1436
1437        viewHolder.messageBoxInner().setMinimumWidth(footerWrap ? (int) (110 * metrics.density) : 0);
1438        LinearLayout.LayoutParams statusParams = (LinearLayout.LayoutParams) viewHolder.statusLine().getLayoutParams();
1439        statusParams.width = footerWrap ? ViewGroup.LayoutParams.MATCH_PARENT : ViewGroup.LayoutParams.WRAP_CONTENT;
1440        viewHolder.statusLine().setLayoutParams(statusParams);
1441
1442        final Function<Reaction, GetThumbnailForCid> reactionThumbnailer = (r) -> new Thumbnailer(conversation.getAccount(), r, conversation.canInferPresence());
1443        if (received) {
1444            if (!muted && commands != null && conversation instanceof Conversation) {
1445                CommandButtonAdapter adapter = new CommandButtonAdapter(activity);
1446                adapter.addAll(commands);
1447                viewHolder.commandsList().setAdapter(adapter);
1448                viewHolder.commandsList().setVisibility(View.VISIBLE);
1449                viewHolder.commandsList().setOnItemClickListener((p, v, pos, id) -> {
1450                    final Element command = adapter.getItem(pos);
1451                    activity.startCommand(conversation.getAccount(), command.getAttributeAsJid("jid"), command.getAttribute("node"));
1452                });
1453            } else {
1454                // It's unclear if we can set this to null...
1455                ListAdapter adapter = viewHolder.commandsList().getAdapter();
1456                if (adapter instanceof ArrayAdapter) {
1457                    ((ArrayAdapter<?>) adapter).clear();
1458                }
1459                viewHolder.commandsList().setVisibility(View.GONE);
1460                viewHolder.commandsList().setOnItemClickListener(null);
1461            }
1462        }
1463
1464        setBackgroundTint(viewHolder.messageBox(), bubbleColor);
1465        setTextColor(viewHolder.messageBody(), bubbleColor);
1466        viewHolder.messageBody().setLinkTextColor(bubbleToOnSurfaceColor(viewHolder.messageBody(), bubbleColor));
1467
1468        if (received && viewHolder instanceof StartBubbleMessageItemViewHolder startViewHolder) {
1469            setTextColor(startViewHolder.encryption(), bubbleColor);
1470            if (isInValidSession) {
1471                startViewHolder.encryption().setVisibility(View.GONE);
1472            } else {
1473                startViewHolder.encryption().setVisibility(View.VISIBLE);
1474                if (omemoEncryption && !message.isTrusted()) {
1475                    startViewHolder.encryption().setText(R.string.not_trusted);
1476                } else {
1477                    startViewHolder
1478                            .encryption()
1479                            .setText(CryptoHelper.encryptionTypeToText(message.getEncryption()));
1480                }
1481            }
1482            final var aggregatedReactions = conversation instanceof Conversation ? ((Conversation) conversation).aggregatedReactionsFor(message, reactionThumbnailer) : message.getAggregatedReactions();
1483            BindingAdapters.setReactionsOnReceived(
1484                    viewHolder.reactions(),
1485                    aggregatedReactions,
1486                    reactions -> sendReactions(message, reactions),
1487                    emoji -> showDetailedReaction(message, emoji),
1488                    emoji -> sendCustomReaction(message, emoji),
1489                    reaction -> removeCustomReaction(conversation, reaction),
1490                    () -> addReaction(message));
1491        } else {
1492            if (viewHolder instanceof StartBubbleMessageItemViewHolder startViewHolder) {
1493                startViewHolder.encryption().setVisibility(View.GONE);
1494            }
1495            BindingAdapters.setReactionsOnSent(
1496                    viewHolder.reactions(),
1497                    message.getAggregatedReactions(),
1498                    reactions -> sendReactions(message, reactions),
1499                    emoji -> showDetailedReaction(message, emoji));
1500        }
1501
1502        var subject = message.getSubject();
1503        if (subject == null && message.getThread() != null) {
1504            final var thread = ((Conversation) message.getConversation()).getThread(message.getThread().getContent());
1505            if (thread != null) subject = thread.getSubject();
1506        }
1507        if (muted || subject == null) {
1508            viewHolder.subject().setVisibility(View.GONE);
1509        } else {
1510            viewHolder.subject().setVisibility(View.VISIBLE);
1511            viewHolder.subject().setText(subject);
1512        }
1513
1514        if (message.getInReplyTo() == null) {
1515            viewHolder.inReplyToBox().setVisibility(View.GONE);
1516        } else {
1517            viewHolder.inReplyToBox().setVisibility(View.VISIBLE);
1518            viewHolder.inReplyTo().setText(UIHelper.getMessageDisplayName(message.getInReplyTo()));
1519            final var replyToClickListener = (View.OnClickListener) (v) -> {
1520                final Message inReplyTo = message.getInReplyTo();
1521                if (inReplyTo == null || inReplyTo.getUuid() == null) return;
1522                final var replyConversation = mConversationFragment.getConversation();
1523                activity.xmppConnectionService.jumpToMessage(replyConversation, inReplyTo.getUuid(), new XmppConnectionService.JumpToMessageListener() {
1524                    @Override
1525                    public void onSuccess() {
1526                        activity.runOnUiThread(() -> mConversationFragment.refresh());
1527                    }
1528
1529                    @Override
1530                    public void onNotFound() {}
1531                });
1532            };
1533            viewHolder.inReplyTo().setOnClickListener(replyToClickListener);
1534            viewHolder.inReplyToQuote().setOnClickListener(replyToClickListener);
1535            setTextColor(viewHolder.inReplyTo(), bubbleColor);
1536        }
1537
1538        if (appSettings.showLinkPreviews()) {
1539            final var descriptions = message.getLinkDescriptions();
1540            viewHolder.linkDescriptions().setAdapter(new ArrayAdapter<>(activity, 0, descriptions) {
1541                @Override
1542                public View getView(int position, View view, @NonNull ViewGroup parent) {
1543                    final LinkDescriptionBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()), R.layout.link_description, parent, false);
1544                    binding.title.setText(getItem(position).findChildContent("title", "https://ogp.me/ns#"));
1545                    binding.description.setText(getItem(position).findChildContent("description", "https://ogp.me/ns#"));
1546                    binding.url.setText(getItem(position).findChildContent("url", "https://ogp.me/ns#"));
1547                    final var video = getItem(position).findChildContent("video", "https://ogp.me/ns#");
1548                    if (video != null && video.length() > 0) {
1549                        binding.playButton.setVisibility(View.VISIBLE);
1550                        binding.playButton.setOnClickListener((v) -> {
1551                            new FixedURLSpan(video).onClick(v);
1552                        });
1553                    }
1554                    return binding.getRoot();
1555                }
1556            });
1557            Util.justifyListViewHeightBasedOnChildren(viewHolder.linkDescriptions(), (int)(metrics.density * 100), true);
1558        }
1559
1560        displayStatus(viewHolder, message, bubbleColor);
1561
1562       viewHolder.messageBody().setAccessibilityDelegate(new View.AccessibilityDelegate() {
1563            @Override
1564            public void sendAccessibilityEvent(View host, int eventType) {
1565                super.sendAccessibilityEvent(host, eventType);
1566                if (eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) {
1567                    if (viewHolder.messageBody().hasSelection()) {
1568                        selectionUuid = message.getUuid();
1569                    } else if (message.getUuid() != null && message.getUuid().equals(selectionUuid)) {
1570                        selectionUuid = null;
1571                    }
1572                }
1573            }
1574        });
1575
1576        return viewHolder.root();
1577    }
1578
1579    private View render(
1580            final Message message, final DateSeperatorMessageItemViewHolder viewHolder) {
1581        final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
1582        if (UIHelper.today(message.getTimeSent())) {
1583            viewHolder.binding.messageBody.setText(R.string.today);
1584        } else if (UIHelper.yesterday(message.getTimeSent())) {
1585            viewHolder.binding.messageBody.setText(R.string.yesterday);
1586        } else {
1587            viewHolder.binding.messageBody.setText(
1588                    DateUtils.formatDateTime(
1589                            activity,
1590                            message.getTimeSent(),
1591                            DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR));
1592        }
1593        if (colorfulBackground) {
1594            setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.PRIMARY);
1595            setTextColor(viewHolder.binding.messageBody, BubbleColor.PRIMARY);
1596        } else {
1597            setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.SURFACE_HIGH);
1598            setTextColor(viewHolder.binding.messageBody, BubbleColor.SURFACE_HIGH);
1599        }
1600        return viewHolder.binding.getRoot();
1601    }
1602
1603    private View render(final Message message, final RtpSessionMessageItemViewHolder viewHolder) {
1604        final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
1605        final boolean received = message.getStatus() <= Message.STATUS_RECEIVED;
1606        final RtpSessionStatus rtpSessionStatus = RtpSessionStatus.of(message.getBody());
1607        final long duration = rtpSessionStatus.duration;
1608        if (received) {
1609            if (duration > 0) {
1610                viewHolder.binding.messageBody.setText(
1611                        activity.getString(
1612                                R.string.incoming_call_duration_timestamp,
1613                                TimeFrameUtils.resolve(activity, duration),
1614                                UIHelper.readableTimeDifferenceFull(
1615                                        activity, message.getTimeSent())));
1616            } else if (rtpSessionStatus.successful) {
1617                viewHolder.binding.messageBody.setText(R.string.incoming_call);
1618            } else {
1619                viewHolder.binding.messageBody.setText(
1620                        activity.getString(
1621                                R.string.missed_call_timestamp,
1622                                UIHelper.readableTimeDifferenceFull(
1623                                        activity, message.getTimeSent())));
1624            }
1625        } else {
1626            if (duration > 0) {
1627                viewHolder.binding.messageBody.setText(
1628                        activity.getString(
1629                                R.string.outgoing_call_duration_timestamp,
1630                                TimeFrameUtils.resolve(activity, duration),
1631                                UIHelper.readableTimeDifferenceFull(
1632                                        activity, message.getTimeSent())));
1633            } else {
1634                viewHolder.binding.messageBody.setText(
1635                        activity.getString(
1636                                R.string.outgoing_call_timestamp,
1637                                UIHelper.readableTimeDifferenceFull(
1638                                        activity, message.getTimeSent())));
1639            }
1640        }
1641        if (colorfulBackground) {
1642            setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.SECONDARY);
1643            setTextColor(viewHolder.binding.messageBody, BubbleColor.SECONDARY);
1644            setImageTint(viewHolder.binding.indicatorReceived, BubbleColor.SECONDARY);
1645        } else {
1646            setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.SURFACE_HIGH);
1647            setTextColor(viewHolder.binding.messageBody, BubbleColor.SURFACE_HIGH);
1648            setImageTint(viewHolder.binding.indicatorReceived, BubbleColor.SURFACE_HIGH);
1649        }
1650        viewHolder.binding.indicatorReceived.setImageResource(
1651                RtpSessionStatus.getDrawable(received, rtpSessionStatus.successful));
1652        return viewHolder.binding.getRoot();
1653    }
1654
1655    private View render(final Message message, final StatusMessageItemViewHolder viewHolder) {
1656        final var conversation = message.getConversation();
1657        if ("LOAD_MORE".equals(message.getBody())) {
1658            viewHolder.binding.statusMessage.setVisibility(View.GONE);
1659            viewHolder.binding.messagePhoto.setVisibility(View.GONE);
1660            viewHolder.binding.loadMoreMessages.setVisibility(View.VISIBLE);
1661            viewHolder.binding.loadMoreMessages.setOnClickListener(
1662                    v -> loadMoreMessages((Conversation) message.getConversation()));
1663        } else {
1664            viewHolder.binding.statusMessage.setVisibility(View.VISIBLE);
1665            viewHolder.binding.loadMoreMessages.setVisibility(View.GONE);
1666            viewHolder.binding.statusMessage.setText(message.getBody());
1667            boolean showAvatar;
1668            if (conversation.getMode() == Conversation.MODE_SINGLE) {
1669                showAvatar = true;
1670                AvatarWorkerTask.loadAvatar(
1671                        message, viewHolder.binding.messagePhoto, R.dimen.avatar_on_status_message);
1672            } else if (message.getCounterpart() != null
1673                    || message.getTrueCounterpart() != null
1674                    || (message.getCounterparts() != null
1675                            && !message.getCounterparts().isEmpty())) {
1676                showAvatar = true;
1677                AvatarWorkerTask.loadAvatar(
1678                        message, viewHolder.binding.messagePhoto, R.dimen.avatar_on_status_message);
1679            } else {
1680                showAvatar = false;
1681            }
1682            if (showAvatar) {
1683                viewHolder.binding.messagePhoto.setAlpha(0.5f);
1684                viewHolder.binding.messagePhoto.setVisibility(View.VISIBLE);
1685            } else {
1686                viewHolder.binding.messagePhoto.setVisibility(View.GONE);
1687            }
1688        }
1689        return viewHolder.binding.getRoot();
1690    }
1691
1692    private void setAvatarDistance(
1693            final LinearLayout messageBox,
1694            final Class<? extends BubbleMessageItemViewHolder> clazz,
1695            final boolean showAvatar) {
1696        final ViewGroup.MarginLayoutParams layoutParams =
1697                (ViewGroup.MarginLayoutParams) messageBox.getLayoutParams();
1698        if (false) { // no need for space since the shape has space inside it for tails
1699            final var resources = messageBox.getResources();
1700            if (clazz == StartBubbleMessageItemViewHolder.class) {
1701                layoutParams.setMarginStart(
1702                        resources.getDimensionPixelSize(R.dimen.bubble_avatar_distance));
1703                layoutParams.setMarginEnd(0);
1704            } else if (clazz == EndBubbleMessageItemViewHolder.class) {
1705                layoutParams.setMarginStart(0);
1706                layoutParams.setMarginEnd(
1707                        resources.getDimensionPixelSize(R.dimen.bubble_avatar_distance));
1708            } else {
1709                throw new AssertionError("Avatar distances are not available on this view type");
1710            }
1711        } else {
1712            layoutParams.setMarginStart(0);
1713            layoutParams.setMarginEnd(0);
1714        }
1715        messageBox.setLayoutParams(layoutParams);
1716    }
1717
1718    private void setBubblePadding(
1719            final ConstraintLayout root,
1720            final boolean mergeIntoTop,
1721            final boolean mergeIntoBottom) {
1722        final var resources = root.getResources();
1723        final var horizontal = resources.getDimensionPixelSize(R.dimen.bubble_horizontal_padding);
1724        final int top =
1725                resources.getDimensionPixelSize(
1726                        mergeIntoTop
1727                                ? R.dimen.bubble_vertical_padding_minimum
1728                                : R.dimen.bubble_vertical_padding);
1729        final int bottom =
1730                resources.getDimensionPixelSize(
1731                        mergeIntoBottom
1732                                ? R.dimen.bubble_vertical_padding_minimum
1733                                : R.dimen.bubble_vertical_padding);
1734        root.setPadding(horizontal, top, horizontal, bottom);
1735    }
1736
1737    private void setRequiresAvatar(
1738            final BubbleMessageItemViewHolder viewHolder, final boolean requiresAvatar) {
1739        final var layoutParams = viewHolder.contactPicture().getLayoutParams();
1740        if (requiresAvatar) {
1741            final var resources = viewHolder.contactPicture().getResources();
1742            final var avatarSize = resources.getDimensionPixelSize(R.dimen.bubble_avatar_size);
1743            layoutParams.height = avatarSize;
1744            viewHolder.contactPicture().setVisibility(View.VISIBLE);
1745            viewHolder.messageBox().setMinimumHeight(avatarSize);
1746        } else {
1747            layoutParams.height = 0;
1748            viewHolder.contactPicture().setVisibility(View.INVISIBLE);
1749            viewHolder.messageBox().setMinimumHeight(0);
1750        }
1751        viewHolder.contactPicture().setLayoutParams(layoutParams);
1752    }
1753
1754    private boolean mergeIntoTop(final int position, final Message message) {
1755        if (position < 0) {
1756            return false;
1757        }
1758        final var top = getItem(position - 1);
1759        return merge(top, message);
1760    }
1761
1762    private boolean mergeIntoBottom(final int position, final Message message) {
1763        final Message bottom;
1764        try {
1765            bottom = getItem(position + 1);
1766        } catch (final IndexOutOfBoundsException e) {
1767            return false;
1768        }
1769        return merge(message, bottom);
1770    }
1771
1772    private static boolean merge(final Message a, final Message b) {
1773        if (getItemViewType(a, false) != getItemViewType(b, false)) {
1774            return false;
1775        }
1776        final var receivedA = a.getStatus() == Message.STATUS_RECEIVED;
1777        final var receivedB = b.getStatus() == Message.STATUS_RECEIVED;
1778        if (receivedA != receivedB) {
1779            return false;
1780        }
1781        if (a.getConversation().getMode() == Conversation.MODE_MULTI
1782                && a.getStatus() == Message.STATUS_RECEIVED) {
1783            final var occupantIdA = a.getOccupantId();
1784            final var occupantIdB = b.getOccupantId();
1785            if (occupantIdA != null && occupantIdB != null) {
1786                if (!occupantIdA.equals(occupantIdB)) {
1787                    return false;
1788                }
1789            }
1790            final var counterPartA = a.getCounterpart();
1791            final var counterPartB = b.getCounterpart();
1792            if (counterPartA == null || !counterPartA.equals(counterPartB)) {
1793                return false;
1794            }
1795        }
1796        final var trueCounterA = a.getTrueCounterpart();
1797        final var trueCounterB = b.getTrueCounterpart();
1798        if ((trueCounterA != null || trueCounterB != null) && (trueCounterA == null || !trueCounterA.equals(trueCounterB))) {
1799            return false;
1800        }
1801        return b.getTimeSent() - a.getTimeSent() <= Config.MESSAGE_MERGE_WINDOW;
1802    }
1803
1804    private boolean showDetailedReaction(final Message message, Map.Entry<EmojiSearch.Emoji, Collection<Reaction>> reaction) {
1805        final var c = message.getConversation();
1806        if (c instanceof Conversation conversation && c.getMode() == Conversational.MODE_MULTI) {
1807            final var reactions = reaction.getValue();
1808            final var mucOptions = conversation.getMucOptions();
1809            final var users = mucOptions.findUsers(reactions);
1810            if (users.isEmpty()) {
1811                return true;
1812            }
1813            final MaterialAlertDialogBuilder dialogBuilder =
1814                    new MaterialAlertDialogBuilder(activity);
1815            dialogBuilder.setTitle(reaction.getKey().toString());
1816            dialogBuilder.setMessage(UIHelper.concatNames(users));
1817            dialogBuilder.create().show();
1818            return true;
1819        } else {
1820            return false;
1821        }
1822    }
1823
1824    private void sendReactions(final Message message, final Collection<String> reactions) {
1825        if (!message.isPrivateMessage() && activity.xmppConnectionService.sendReactions(message, reactions)) {
1826            return;
1827        }
1828        Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
1829    }
1830
1831    private void sendCustomReaction(final Message inReplyTo, final EmojiSearch.CustomEmoji emoji) {
1832        final var message = inReplyTo.reply();
1833        message.appendBody(emoji.toInsert());
1834        Message.configurePrivateMessage(message);
1835        new Thread(() -> activity.xmppConnectionService.sendMessage(message)).start();
1836    }
1837
1838    private void removeCustomReaction(final Conversational conversation, final Reaction reaction) {
1839        if (!(conversation instanceof Conversation)) {
1840            Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
1841            return;
1842        }
1843
1844        final var message = new Message(conversation, " ", ((Conversation) conversation).getNextEncryption());
1845        final var envelope = ((Conversation) conversation).findMessageWithUuidOrRemoteId(reaction.envelopeId);
1846        if (envelope != null) {
1847            ((Conversation) conversation).remove(envelope);
1848            message.addPayload(envelope.getReply());
1849            message.getOrMakeHtml();
1850            message.putEdited(reaction.envelopeId, envelope.getServerMsgId());
1851        } else {
1852            message.putEdited(reaction.envelopeId, null);
1853        }
1854
1855        new Thread(() -> activity.xmppConnectionService.sendMessage(message)).start();
1856    }
1857
1858    private void addReaction(final Message message) {
1859        if (mConversationFragment == null) return;
1860        mConversationFragment.addReaction(message);
1861    }
1862
1863    private void promptOpenKeychainInstall(View view) {
1864        activity.showInstallPgpDialog();
1865    }
1866
1867    public FileBackend getFileBackend() {
1868        return activity.xmppConnectionService.getFileBackend();
1869    }
1870
1871    public void stopAudioPlayer() {
1872        audioPlayer.stop();
1873    }
1874
1875    public void unregisterListenerInAudioPlayer() {
1876        audioPlayer.unregisterListener();
1877    }
1878
1879    public void startStopPending() {
1880        audioPlayer.startStopPending();
1881    }
1882
1883    public void openDownloadable(Message message) {
1884        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
1885                && ContextCompat.checkSelfPermission(
1886                                activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)
1887                        != PackageManager.PERMISSION_GRANTED) {
1888            ConversationFragment.registerPendingMessage(activity, message);
1889            ActivityCompat.requestPermissions(
1890                    activity,
1891                    new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE},
1892                    ConversationsActivity.REQUEST_OPEN_MESSAGE);
1893            return;
1894        }
1895        final DownloadableFile file =
1896                activity.xmppConnectionService.getFileBackend().getFile(message);
1897        final var fp = message.getFileParams();
1898        final var name = fp == null ? null : fp.getName();
1899        final var displayName = name == null ? file.getName() : name;
1900        ViewUtil.view(activity, file, displayName);
1901    }
1902
1903    private void showLocation(Message message) {
1904        for (Intent intent : GeoHelper.createGeoIntentsFromMessage(activity, message)) {
1905            if (intent.resolveActivity(getContext().getPackageManager()) != null) {
1906                getContext().startActivity(intent);
1907                return;
1908            }
1909        }
1910        Toast.makeText(
1911                        activity,
1912                        R.string.no_application_found_to_display_location,
1913                        Toast.LENGTH_SHORT)
1914                .show();
1915    }
1916
1917    public void updatePreferences() {
1918        this.bubbleDesign =
1919                new BubbleDesign(
1920                        appSettings.isColorfulChatBubbles(),
1921                        appSettings.isAlignStart(),
1922                        appSettings.isLargeFont(),
1923                        appSettings.isShowAvatars());
1924    }
1925
1926    public void setHighlightedTerm(List<String> terms) {
1927        this.highlightedTerm = terms == null ? null : StylingHelper.filterHighlightedWords(terms);
1928    }
1929
1930    public interface OnContactPictureClicked {
1931        void onContactPictureClicked(Message message);
1932    }
1933
1934    public interface OnContactPictureLongClicked {
1935        void onContactPictureLongClicked(View v, Message message);
1936    }
1937
1938    public interface OnInlineImageLongClicked {
1939        boolean onInlineImageLongClicked(Cid cid);
1940    }
1941
1942    private static void setBackgroundTint(final LinearLayout view, final BubbleColor bubbleColor) {
1943        view.setBackgroundTintList(bubbleToColorStateList(view, bubbleColor));
1944    }
1945
1946    private static ColorStateList bubbleToColorStateList(
1947            final View view, final BubbleColor bubbleColor) {
1948        final @AttrRes int colorAttributeResId =
1949                switch (bubbleColor) {
1950                    case SURFACE ->
1951                            Activities.isNightMode(view.getContext())
1952                                    ? com.google.android.material.R.attr.colorSurfaceContainerHigh
1953                                    : com.google.android.material.R.attr.colorSurfaceContainerLow;
1954                    case SURFACE_HIGH ->
1955                            Activities.isNightMode(view.getContext())
1956                                    ? com.google.android.material.R.attr
1957                                            .colorSurfaceContainerHighest
1958                                    : com.google.android.material.R.attr.colorSurfaceContainerHigh;
1959                    case PRIMARY -> com.google.android.material.R.attr.colorPrimaryContainer;
1960                    case SECONDARY -> com.google.android.material.R.attr.colorSecondaryContainer;
1961                    case TERTIARY -> com.google.android.material.R.attr.colorTertiaryContainer;
1962                    case WARNING -> com.google.android.material.R.attr.colorErrorContainer;
1963                };
1964        return ColorStateList.valueOf(MaterialColors.getColor(view, colorAttributeResId));
1965    }
1966
1967    public static void setImageTint(final ImageView imageView, final BubbleColor bubbleColor) {
1968        ImageViewCompat.setImageTintList(
1969                imageView, bubbleToOnSurfaceColorStateList(imageView, bubbleColor));
1970    }
1971
1972    public static void setImageTintError(final ImageView imageView) {
1973        ImageViewCompat.setImageTintList(
1974                imageView,
1975                ColorStateList.valueOf(
1976                        MaterialColors.getColor(imageView, androidx.appcompat.R.attr.colorError)));
1977    }
1978
1979    public static void setTextColor(final TextView textView, final BubbleColor bubbleColor) {
1980        final var color = bubbleToOnSurfaceColor(textView, bubbleColor);
1981        textView.setTextColor(color);
1982        if (BubbleColor.SURFACES.contains(bubbleColor)) {
1983            textView.setLinkTextColor(
1984                    MaterialColors.getColor(textView, androidx.appcompat.R.attr.colorPrimary));
1985        } else {
1986            textView.setLinkTextColor(color);
1987        }
1988    }
1989
1990    private static void setTextSize(final TextView textView, final boolean largeFont) {
1991        if (largeFont) {
1992            textView.setTextAppearance(
1993                    R.style.TextAppearance_Snikket_MessageContentLarge);
1994        } else {
1995            textView.setTextAppearance(
1996                    R.style.TextAppearance_Snikket_MessageContentNormal);
1997        }
1998    }
1999
2000    private static @ColorInt int bubbleToOnSurfaceVariant(
2001            final View view, final BubbleColor bubbleColor) {
2002        final @AttrRes int colorAttributeResId;
2003        if (BubbleColor.SURFACES.contains(bubbleColor)) {
2004            colorAttributeResId = com.google.android.material.R.attr.colorOnSurfaceVariant;
2005        } else {
2006            colorAttributeResId = bubbleToOnSurface(bubbleColor);
2007        }
2008        return MaterialColors.getColor(view, colorAttributeResId);
2009    }
2010
2011    private static @ColorInt int bubbleToOnSurfaceColor(
2012            final View view, final BubbleColor bubbleColor) {
2013        return MaterialColors.getColor(view, bubbleToOnSurface(bubbleColor));
2014    }
2015
2016    public static ColorStateList bubbleToOnSurfaceColorStateList(
2017            final View view, final BubbleColor bubbleColor) {
2018        return ColorStateList.valueOf(bubbleToOnSurfaceColor(view, bubbleColor));
2019    }
2020
2021    private static @AttrRes int bubbleToOnSurface(final BubbleColor bubbleColor) {
2022        return switch (bubbleColor) {
2023            case SURFACE, SURFACE_HIGH -> com.google.android.material.R.attr.colorOnSurface;
2024            case PRIMARY -> com.google.android.material.R.attr.colorOnPrimaryContainer;
2025            case SECONDARY -> com.google.android.material.R.attr.colorOnSecondaryContainer;
2026            case TERTIARY -> com.google.android.material.R.attr.colorOnTertiaryContainer;
2027            case WARNING -> com.google.android.material.R.attr.colorOnErrorContainer;
2028        };
2029    }
2030
2031    public enum BubbleColor {
2032        SURFACE,
2033        SURFACE_HIGH,
2034        PRIMARY,
2035        SECONDARY,
2036        TERTIARY,
2037        WARNING;
2038
2039        private static final Collection<BubbleColor> SURFACES =
2040                Arrays.asList(BubbleColor.SURFACE, BubbleColor.SURFACE_HIGH);
2041    }
2042
2043    private static class BubbleDesign {
2044        public final boolean colorfulChatBubbles;
2045        public final boolean alignStart;
2046        public final boolean largeFont;
2047        public final boolean showAvatars;
2048
2049        private BubbleDesign(
2050                final boolean colorfulChatBubbles,
2051                final boolean alignStart,
2052                final boolean largeFont,
2053                final boolean showAvatars) {
2054            this.colorfulChatBubbles = colorfulChatBubbles;
2055            this.alignStart = alignStart;
2056            this.largeFont = largeFont;
2057            this.showAvatars = showAvatars;
2058        }
2059    }
2060
2061    private abstract static class MessageItemViewHolder /*extends RecyclerView.ViewHolder*/ {
2062
2063        private final View itemView;
2064
2065        private MessageItemViewHolder(@NonNull View itemView) {
2066            this.itemView = itemView;
2067        }
2068    }
2069
2070    private abstract static class BubbleMessageItemViewHolder extends MessageItemViewHolder {
2071
2072        private BubbleMessageItemViewHolder(@NonNull View itemView) {
2073            super(itemView);
2074        }
2075
2076        public abstract ConstraintLayout root();
2077
2078        protected abstract ImageView indicatorEdit();
2079
2080        protected abstract RelativeLayout audioPlayer();
2081
2082        protected abstract LinearLayout messageBox();
2083
2084        protected abstract MaterialButton downloadButton();
2085
2086        protected abstract ShapeableImageView image();
2087
2088        protected abstract ImageView indicatorSecurity();
2089
2090        protected abstract ImageView indicatorReceived();
2091
2092        protected abstract TextView time();
2093
2094        protected abstract TextView messageBody();
2095
2096        protected abstract ImageView contactPicture();
2097
2098        protected abstract ChipGroup reactions();
2099
2100        protected abstract ListView commandsList();
2101
2102        protected abstract View messageBoxInner();
2103
2104        protected abstract View statusLine();
2105
2106        protected abstract GithubIdenticonView threadIdenticon();
2107
2108        protected abstract ListView linkDescriptions();
2109
2110        protected abstract LinearLayout inReplyToBox();
2111
2112        protected abstract TextView inReplyTo();
2113
2114        protected abstract TextView inReplyToQuote();
2115
2116        protected abstract TextView subject();
2117    }
2118
2119    private static class StartBubbleMessageItemViewHolder extends BubbleMessageItemViewHolder {
2120
2121        private final ItemMessageStartBinding binding;
2122
2123        public StartBubbleMessageItemViewHolder(@NonNull ItemMessageStartBinding binding) {
2124            super(binding.getRoot());
2125            this.binding = binding;
2126        }
2127
2128        @Override
2129        public ConstraintLayout root() {
2130            return (ConstraintLayout) this.binding.getRoot();
2131        }
2132
2133        @Override
2134        protected ImageView indicatorEdit() {
2135            return this.binding.editIndicator;
2136        }
2137
2138        @Override
2139        protected RelativeLayout audioPlayer() {
2140            return this.binding.messageContent.audioPlayer;
2141        }
2142
2143        @Override
2144        protected LinearLayout messageBox() {
2145            return this.binding.messageBox;
2146        }
2147
2148        @Override
2149        protected MaterialButton downloadButton() {
2150            return this.binding.messageContent.downloadButton;
2151        }
2152
2153        @Override
2154        protected ShapeableImageView image() {
2155            return this.binding.messageContent.messageImage;
2156        }
2157
2158        protected ImageView indicatorSecurity() {
2159            return this.binding.securityIndicator;
2160        }
2161
2162        @Override
2163        protected ImageView indicatorReceived() {
2164            return this.binding.indicatorReceived;
2165        }
2166
2167        @Override
2168        protected TextView time() {
2169            return this.binding.messageTime;
2170        }
2171
2172        @Override
2173        protected TextView messageBody() {
2174            return this.binding.messageContent.messageBody;
2175        }
2176
2177        protected TextView encryption() {
2178            return this.binding.messageEncryption;
2179        }
2180
2181        @Override
2182        protected ImageView contactPicture() {
2183            return this.binding.messagePhoto;
2184        }
2185
2186        @Override
2187        protected ChipGroup reactions() {
2188            return this.binding.reactions;
2189        }
2190
2191        @Override
2192        protected ListView commandsList() {
2193            return this.binding.messageContent.commandsList;
2194        }
2195
2196        @Override
2197        protected View messageBoxInner() {
2198            return this.binding.messageBoxInner;
2199        }
2200
2201        @Override
2202        protected View statusLine() {
2203            return this.binding.statusLine;
2204        }
2205
2206        @Override
2207        protected GithubIdenticonView threadIdenticon() {
2208            return this.binding.threadIdenticon;
2209        }
2210
2211        @Override
2212        protected ListView linkDescriptions() {
2213            return this.binding.messageContent.linkDescriptions;
2214        }
2215
2216        @Override
2217        protected LinearLayout inReplyToBox() {
2218            return this.binding.messageContent.inReplyToBox;
2219        }
2220
2221        @Override
2222        protected TextView inReplyTo() {
2223            return this.binding.messageContent.inReplyTo;
2224        }
2225
2226        @Override
2227        protected TextView inReplyToQuote() {
2228            return this.binding.messageContent.inReplyToQuote;
2229        }
2230
2231        @Override
2232        protected TextView subject() {
2233            return this.binding.messageSubject;
2234        }
2235    }
2236
2237    private static class EndBubbleMessageItemViewHolder extends BubbleMessageItemViewHolder {
2238
2239        private final ItemMessageEndBinding binding;
2240
2241        private EndBubbleMessageItemViewHolder(@NonNull ItemMessageEndBinding binding) {
2242            super(binding.getRoot());
2243            this.binding = binding;
2244        }
2245
2246        @Override
2247        public ConstraintLayout root() {
2248            return (ConstraintLayout) this.binding.getRoot();
2249        }
2250
2251        @Override
2252        protected ImageView indicatorEdit() {
2253            return this.binding.editIndicator;
2254        }
2255
2256        @Override
2257        protected RelativeLayout audioPlayer() {
2258            return this.binding.messageContent.audioPlayer;
2259        }
2260
2261        @Override
2262        protected LinearLayout messageBox() {
2263            return this.binding.messageBox;
2264        }
2265
2266        @Override
2267        protected MaterialButton downloadButton() {
2268            return this.binding.messageContent.downloadButton;
2269        }
2270
2271        @Override
2272        protected ShapeableImageView image() {
2273            return this.binding.messageContent.messageImage;
2274        }
2275
2276        @Override
2277        protected ImageView indicatorSecurity() {
2278            return this.binding.securityIndicator;
2279        }
2280
2281        @Override
2282        protected ImageView indicatorReceived() {
2283            return this.binding.indicatorReceived;
2284        }
2285
2286        @Override
2287        protected TextView time() {
2288            return this.binding.messageTime;
2289        }
2290
2291        @Override
2292        protected TextView messageBody() {
2293            return this.binding.messageContent.messageBody;
2294        }
2295
2296        @Override
2297        protected ImageView contactPicture() {
2298            return this.binding.messagePhoto;
2299        }
2300
2301        @Override
2302        protected ChipGroup reactions() {
2303            return this.binding.reactions;
2304        }
2305
2306        @Override
2307        protected ListView commandsList() {
2308            return this.binding.messageContent.commandsList;
2309        }
2310
2311        @Override
2312        protected View messageBoxInner() {
2313            return this.binding.messageBoxInner;
2314        }
2315
2316        @Override
2317        protected View statusLine() {
2318            return this.binding.statusLine;
2319        }
2320
2321        @Override
2322        protected GithubIdenticonView threadIdenticon() {
2323            return this.binding.threadIdenticon;
2324        }
2325
2326        @Override
2327        protected ListView linkDescriptions() {
2328            return this.binding.messageContent.linkDescriptions;
2329        }
2330
2331        @Override
2332        protected LinearLayout inReplyToBox() {
2333            return this.binding.messageContent.inReplyToBox;
2334        }
2335
2336        @Override
2337        protected TextView inReplyTo() {
2338            return this.binding.messageContent.inReplyTo;
2339        }
2340
2341        @Override
2342        protected TextView inReplyToQuote() {
2343            return this.binding.messageContent.inReplyToQuote;
2344        }
2345
2346        @Override
2347        protected TextView subject() {
2348            return this.binding.messageSubject;
2349        }
2350    }
2351
2352    private static class DateSeperatorMessageItemViewHolder extends MessageItemViewHolder {
2353
2354        private final ItemMessageDateBubbleBinding binding;
2355
2356        private DateSeperatorMessageItemViewHolder(@NonNull ItemMessageDateBubbleBinding binding) {
2357            super(binding.getRoot());
2358            this.binding = binding;
2359        }
2360    }
2361
2362    private static class RtpSessionMessageItemViewHolder extends MessageItemViewHolder {
2363
2364        private final ItemMessageRtpSessionBinding binding;
2365
2366        private RtpSessionMessageItemViewHolder(@NonNull ItemMessageRtpSessionBinding binding) {
2367            super(binding.getRoot());
2368            this.binding = binding;
2369        }
2370    }
2371
2372    private static class StatusMessageItemViewHolder extends MessageItemViewHolder {
2373
2374        private final ItemMessageStatusBinding binding;
2375
2376        private StatusMessageItemViewHolder(@NonNull ItemMessageStatusBinding binding) {
2377            super(binding.getRoot());
2378            this.binding = binding;
2379        }
2380    }
2381
2382    class Thumbnailer implements GetThumbnailForCid {
2383        final Account account;
2384        final boolean canFetch;
2385        final Jid counterpart;
2386
2387        public Thumbnailer(final Message message) {
2388            account = message.getConversation().getAccount();
2389            canFetch = message.trusted() || message.getConversation().canInferPresence();
2390            counterpart = message.getCounterpart();
2391        }
2392
2393        public Thumbnailer(final Account account, final Reaction reaction, final boolean allowFetch) {
2394            canFetch = allowFetch;
2395            counterpart = reaction.from;
2396            this.account = account;
2397        }
2398
2399        @Override
2400        public Drawable getThumbnail(Cid cid) {
2401            try {
2402                DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
2403                if (f == null || !f.canRead()) {
2404                    if (!canFetch) return null;
2405
2406                    try {
2407                        new BobTransfer(BobTransfer.uri(cid), account, counterpart, activity.xmppConnectionService).start();
2408                    } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
2409                    return null;
2410                }
2411
2412                Drawable d = activity.xmppConnectionService.getFileBackend().getThumbnail(f, activity.getResources(), (int) (metrics.density * 288), true);
2413                if (d == null) {
2414                    new ThumbnailTask().execute(f);
2415                }
2416                return d;
2417            } catch (final IOException e) {
2418                return null;
2419            }
2420        }
2421    }
2422
2423    class ThumbnailTask extends AsyncTask<DownloadableFile, Void, Drawable[]> {
2424        @Override
2425        protected Drawable[] doInBackground(DownloadableFile... params) {
2426            if (isCancelled()) return null;
2427
2428            Drawable[] d = new Drawable[params.length];
2429            for (int i = 0; i < params.length; i++) {
2430                try {
2431                    d[i] = activity.xmppConnectionService.getFileBackend().getThumbnail(params[i], activity.getResources(), (int) (metrics.density * 288), false);
2432                } catch (final IOException e) {
2433                    d[i] = null;
2434                }
2435            }
2436
2437            return d;
2438        }
2439
2440        @Override
2441        protected void onPostExecute(final Drawable[] d) {
2442            if (isCancelled()) return;
2443            activity.xmppConnectionService.updateConversationUi();
2444        }
2445    }
2446}