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 androidx.emoji2.emojipicker.EmojiViewItem;
  71import androidx.emoji2.emojipicker.RecentEmojiProvider;
  72
  73import com.google.android.material.button.MaterialButton;
  74import com.google.android.material.chip.ChipGroup;
  75import com.google.android.material.color.MaterialColors;
  76import com.google.android.material.dialog.MaterialAlertDialogBuilder;
  77import com.google.common.base.Joiner;
  78import com.google.common.base.Strings;
  79import com.google.common.collect.Collections2;
  80import com.google.common.collect.ImmutableList;
  81import com.google.common.collect.ImmutableSet;
  82
  83import com.lelloman.identicon.view.GithubIdenticonView;
  84
  85import io.ipfs.cid.Cid;
  86
  87import java.io.IOException;
  88import java.net.URI;
  89import java.net.URISyntaxException;
  90import java.security.NoSuchAlgorithmException;
  91import java.util.HashMap;
  92import java.util.List;
  93import java.util.Map;
  94import java.util.Locale;
  95import java.util.function.Function;
  96import java.util.regex.Matcher;
  97import java.util.regex.Pattern;
  98
  99import me.saket.bettermovementmethod.BetterLinkMovementMethod;
 100
 101import net.fellbaum.jemoji.EmojiManager;
 102
 103import eu.siacs.conversations.AppSettings;
 104import eu.siacs.conversations.Config;
 105import eu.siacs.conversations.R;
 106import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
 107import eu.siacs.conversations.databinding.LinkDescriptionBinding;
 108import eu.siacs.conversations.databinding.DialogAddReactionBinding;
 109import eu.siacs.conversations.databinding.ItemMessageDateBubbleBinding;
 110import eu.siacs.conversations.databinding.ItemMessageEndBinding;
 111import eu.siacs.conversations.databinding.ItemMessageRtpSessionBinding;
 112import eu.siacs.conversations.databinding.ItemMessageStartBinding;
 113import eu.siacs.conversations.databinding.ItemMessageStatusBinding;
 114import eu.siacs.conversations.entities.Account;
 115import eu.siacs.conversations.entities.Contact;
 116import eu.siacs.conversations.entities.Conversation;
 117import eu.siacs.conversations.entities.Conversational;
 118import eu.siacs.conversations.entities.DownloadableFile;
 119import eu.siacs.conversations.entities.Message.FileParams;
 120import eu.siacs.conversations.entities.Message;
 121import eu.siacs.conversations.entities.MucOptions;
 122import eu.siacs.conversations.entities.Reaction;
 123import eu.siacs.conversations.entities.Roster;
 124import eu.siacs.conversations.entities.RtpSessionStatus;
 125import eu.siacs.conversations.entities.Transferable;
 126import eu.siacs.conversations.persistance.FileBackend;
 127import eu.siacs.conversations.services.MessageArchiveService;
 128import eu.siacs.conversations.services.NotificationService;
 129import eu.siacs.conversations.ui.Activities;
 130import eu.siacs.conversations.ui.BindingAdapters;
 131import eu.siacs.conversations.ui.ConversationFragment;
 132import eu.siacs.conversations.ui.ConversationsActivity;
 133import eu.siacs.conversations.ui.XmppActivity;
 134import eu.siacs.conversations.ui.service.AudioPlayer;
 135import eu.siacs.conversations.ui.text.DividerSpan;
 136import eu.siacs.conversations.ui.text.FixedURLSpan;
 137import eu.siacs.conversations.ui.text.QuoteSpan;
 138import eu.siacs.conversations.ui.util.Attachment;
 139import eu.siacs.conversations.ui.util.AvatarWorkerTask;
 140import eu.siacs.conversations.ui.util.MyLinkify;
 141import eu.siacs.conversations.ui.util.QuoteHelper;
 142import eu.siacs.conversations.ui.util.ShareUtil;
 143import eu.siacs.conversations.ui.util.ViewUtil;
 144import eu.siacs.conversations.utils.CryptoHelper;
 145import eu.siacs.conversations.utils.Emoticons;
 146import eu.siacs.conversations.utils.GeoHelper;
 147import eu.siacs.conversations.utils.MessageUtils;
 148import eu.siacs.conversations.utils.StylingHelper;
 149import eu.siacs.conversations.utils.TimeFrameUtils;
 150import eu.siacs.conversations.utils.UIHelper;
 151import eu.siacs.conversations.xmpp.Jid;
 152import eu.siacs.conversations.xmpp.mam.MamReference;
 153import eu.siacs.conversations.xml.Element;
 154import kotlin.coroutines.Continuation;
 155
 156import java.net.URI;
 157import java.util.Arrays;
 158import java.util.Collection;
 159import java.util.List;
 160import java.util.Locale;
 161import java.util.regex.Matcher;
 162import java.util.regex.Pattern;
 163
 164public class MessageAdapter extends ArrayAdapter<Message> {
 165
 166    public static final String DATE_SEPARATOR_BODY = "DATE_SEPARATOR";
 167    private static final int END = 0;
 168    private static final int START = 1;
 169    private static final int STATUS = 2;
 170    private static final int DATE_SEPARATOR = 3;
 171    private static final int RTP_SESSION = 4;
 172    private final XmppActivity activity;
 173    private final AudioPlayer audioPlayer;
 174    private List<String> highlightedTerm = null;
 175    private final DisplayMetrics metrics;
 176    private ConversationFragment mConversationFragment = null;
 177    private OnContactPictureClicked mOnContactPictureClickedListener;
 178    private OnContactPictureClicked mOnMessageBoxClickedListener;
 179    private OnContactPictureClicked mOnMessageBoxSwipedListener;
 180    private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
 181    private OnInlineImageLongClicked mOnInlineImageLongClickedListener;
 182    private BubbleDesign bubbleDesign = new BubbleDesign(false, false, false, true);
 183    private final boolean mForceNames;
 184    private final Map<String, WebxdcUpdate> lastWebxdcUpdate = new HashMap<>();
 185    private String selectionUuid = null;
 186    private final AppSettings appSettings;
 187
 188    public MessageAdapter(
 189            final XmppActivity activity, final List<Message> messages, final boolean forceNames) {
 190        super(activity, 0, messages);
 191        this.audioPlayer = new AudioPlayer(this);
 192        this.activity = activity;
 193        metrics = getContext().getResources().getDisplayMetrics();
 194        appSettings = new AppSettings(activity);
 195        updatePreferences();
 196        this.mForceNames = forceNames;
 197    }
 198
 199    public MessageAdapter(final XmppActivity activity, final List<Message> messages) {
 200        this(activity, messages, false);
 201    }
 202
 203    private static void resetClickListener(View... views) {
 204        for (View view : views) {
 205            if (view != null) view.setOnClickListener(null);
 206        }
 207    }
 208
 209    public void flagScreenOn() {
 210        activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
 211    }
 212
 213    public void flagScreenOff() {
 214        activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
 215    }
 216
 217    public void setVolumeControl(final int stream) {
 218        activity.setVolumeControlStream(stream);
 219    }
 220
 221    public void setOnContactPictureClicked(OnContactPictureClicked listener) {
 222        this.mOnContactPictureClickedListener = listener;
 223    }
 224
 225    public void setOnMessageBoxClicked(OnContactPictureClicked listener) {
 226        this.mOnMessageBoxClickedListener = listener;
 227    }
 228
 229    public void setOnMessageBoxSwiped(OnContactPictureClicked listener) {
 230        this.mOnMessageBoxSwipedListener = listener;
 231    }
 232
 233    public void setConversationFragment(ConversationFragment frag) {
 234        mConversationFragment = frag;
 235    }
 236
 237    public void quoteText(String text) {
 238        if (mConversationFragment != null) mConversationFragment.quoteText(text);
 239    }
 240
 241    public boolean hasSelection() {
 242        return selectionUuid != null;
 243    }
 244
 245    public Activity getActivity() {
 246        return activity;
 247    }
 248
 249    public void setOnContactPictureLongClicked(OnContactPictureLongClicked listener) {
 250        this.mOnContactPictureLongClickedListener = listener;
 251    }
 252
 253    public void setOnInlineImageLongClicked(OnInlineImageLongClicked listener) {
 254        this.mOnInlineImageLongClickedListener = listener;
 255    }
 256
 257    @Override
 258    public int getViewTypeCount() {
 259        return 5;
 260    }
 261
 262    private static int getItemViewType(final Message message, final boolean alignStart) {
 263        if (message.getType() == Message.TYPE_STATUS) {
 264            if (DATE_SEPARATOR_BODY.equals(message.getBody())) {
 265                return DATE_SEPARATOR;
 266            } else {
 267                return STATUS;
 268            }
 269        } else if (message.getType() == Message.TYPE_RTP_SESSION) {
 270            return RTP_SESSION;
 271        } else if (message.getStatus() <= Message.STATUS_RECEIVED || alignStart) {
 272            return START;
 273        } else {
 274            return END;
 275        }
 276    }
 277
 278    @Override
 279    public int getItemViewType(final int position) {
 280        return getItemViewType(getItem(position), bubbleDesign.alignStart);
 281    }
 282
 283    private void displayStatus(
 284            final BubbleMessageItemViewHolder viewHolder,
 285            final Message message,
 286            final BubbleColor bubbleColor) {
 287        final int status = message.getStatus();
 288        final boolean error;
 289        final Transferable transferable = message.getTransferable();
 290        final boolean multiReceived =
 291                message.getConversation().getMode() == Conversation.MODE_MULTI
 292                        && message.getStatus() <= Message.STATUS_RECEIVED;
 293        final boolean sent = status != Message.STATUS_RECEIVED;
 294        final boolean showUserNickname =
 295                message.getConversation().getMode() == Conversation.MODE_MULTI
 296                        && viewHolder instanceof StartBubbleMessageItemViewHolder;
 297        final String fileSize;
 298        if (message.isFileOrImage()
 299                || transferable != null
 300                || MessageUtils.unInitiatedButKnownSize(message)) {
 301            final FileParams params = message.getFileParams();
 302            fileSize = params.size != null ? UIHelper.filesizeToString(params.size) : null;
 303            if (message.getStatus() == Message.STATUS_SEND_FAILED
 304                    || (transferable != null
 305                            && (transferable.getStatus() == Transferable.STATUS_FAILED
 306                                    || transferable.getStatus()
 307                                            == Transferable.STATUS_CANCELLED))) {
 308                error = true;
 309            } else {
 310                error = message.getStatus() == Message.STATUS_SEND_FAILED;
 311            }
 312        } else {
 313            fileSize = null;
 314            error = message.getStatus() == Message.STATUS_SEND_FAILED;
 315        }
 316
 317        if (sent) {
 318            final @DrawableRes Integer receivedIndicator =
 319                    getMessageStatusAsDrawable(message, status);
 320            if (receivedIndicator == null) {
 321                viewHolder.indicatorReceived().setVisibility(View.INVISIBLE);
 322            } else {
 323                viewHolder.indicatorReceived().setImageResource(receivedIndicator);
 324                if (status == Message.STATUS_SEND_FAILED) {
 325                    setImageTintError(viewHolder.indicatorReceived());
 326                } else {
 327                    setImageTint(viewHolder.indicatorReceived(), bubbleColor);
 328                }
 329                viewHolder.indicatorReceived().setVisibility(View.VISIBLE);
 330            }
 331        } else {
 332            viewHolder.indicatorReceived().setVisibility(View.GONE);
 333        }
 334        final var additionalStatusInfo = getAdditionalStatusInfo(message, status);
 335
 336        if (error && sent) {
 337            viewHolder
 338                    .time()
 339                    .setTextColor(
 340                            MaterialColors.getColor(
 341                                    viewHolder.time(),
 342                                    com.google.android.material.R.attr.colorError));
 343        } else {
 344            setTextColor(viewHolder.time(), bubbleColor);
 345        }
 346        setTextColor(viewHolder.subject(), bubbleColor);
 347        if (message.getEncryption() == Message.ENCRYPTION_NONE) {
 348            viewHolder.indicatorSecurity().setVisibility(View.GONE);
 349        } else {
 350            boolean verified = false;
 351            if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
 352                final FingerprintStatus fingerprintStatus =
 353                        message.getConversation()
 354                                .getAccount()
 355                                .getAxolotlService()
 356                                .getFingerprintTrust(message.getFingerprint());
 357                if (fingerprintStatus != null && fingerprintStatus.isVerified()) {
 358                    verified = true;
 359                }
 360            }
 361            if (verified) {
 362                viewHolder.indicatorSecurity().setImageResource(R.drawable.ic_verified_user_24dp);
 363            } else {
 364                viewHolder.indicatorSecurity().setImageResource(R.drawable.ic_lock_24dp);
 365            }
 366            if (error && sent) {
 367                setImageTintError(viewHolder.indicatorSecurity());
 368            } else {
 369                setImageTint(viewHolder.indicatorSecurity(), bubbleColor);
 370            }
 371            viewHolder.indicatorSecurity().setVisibility(View.VISIBLE);
 372        }
 373
 374        if (message.edited()) {
 375            viewHolder.indicatorEdit().setVisibility(View.VISIBLE);
 376            if (error && sent) {
 377                setImageTintError(viewHolder.indicatorEdit());
 378            } else {
 379                setImageTint(viewHolder.indicatorEdit(), bubbleColor);
 380            }
 381        } else {
 382            viewHolder.indicatorEdit().setVisibility(View.GONE);
 383        }
 384
 385        final String formattedTime =
 386                UIHelper.readableTimeDifferenceFull(getContext(), message.getTimeSent());
 387        final String bodyLanguage = message.getBodyLanguage();
 388        final ImmutableList.Builder<String> timeInfoBuilder = new ImmutableList.Builder<>();
 389
 390        if (mForceNames || multiReceived || showUserNickname || (message.getTrueCounterpart() != null && message.getContact() != null)) {
 391            final String displayName = UIHelper.getMessageDisplayName(message);
 392            if (displayName != null) {
 393                timeInfoBuilder.add(displayName);
 394            }
 395        }
 396        if (fileSize != null) {
 397            timeInfoBuilder.add(fileSize);
 398        }
 399        if (bodyLanguage != null) {
 400            timeInfoBuilder.add(bodyLanguage.toUpperCase(Locale.US));
 401        }
 402        // for space reasons we display only 'additional status info' (send progress or concrete
 403        // failure reason) or the time
 404        if (additionalStatusInfo != null) {
 405            timeInfoBuilder.add(additionalStatusInfo);
 406        } else {
 407            timeInfoBuilder.add(formattedTime);
 408        }
 409        final var timeInfo = timeInfoBuilder.build();
 410        viewHolder.time().setText(Joiner.on(" · ").join(timeInfo));
 411    }
 412
 413    public static @DrawableRes Integer getMessageStatusAsDrawable(
 414            final Message message, final int status) {
 415        final var transferable = message.getTransferable();
 416        return switch (status) {
 417            case Message.STATUS_WAITING -> R.drawable.ic_more_horiz_24dp;
 418            case Message.STATUS_UNSEND -> transferable == null ? null : R.drawable.ic_upload_24dp;
 419            case Message.STATUS_SEND -> R.drawable.ic_done_24dp;
 420            case Message.STATUS_SEND_RECEIVED, Message.STATUS_SEND_DISPLAYED ->
 421                    R.drawable.ic_done_all_24dp;
 422            case Message.STATUS_SEND_FAILED -> {
 423                final String errorMessage = message.getErrorMessage();
 424                if (Message.ERROR_MESSAGE_CANCELLED.equals(errorMessage)) {
 425                    yield R.drawable.ic_cancel_24dp;
 426                } else {
 427                    yield R.drawable.ic_error_24dp;
 428                }
 429            }
 430            case Message.STATUS_OFFERED -> R.drawable.ic_p2p_24dp;
 431            default -> null;
 432        };
 433    }
 434
 435    @Nullable
 436    private String getAdditionalStatusInfo(final Message message, final int mergedStatus) {
 437        final String additionalStatusInfo;
 438        if (mergedStatus == Message.STATUS_SEND_FAILED) {
 439            final String errorMessage = Strings.nullToEmpty(message.getErrorMessage());
 440            final String[] errorParts = errorMessage.split("\\u001f", 2);
 441            if (errorParts.length == 2 && errorParts[0].equals("file-too-large")) {
 442                additionalStatusInfo = getContext().getString(R.string.file_too_large);
 443            } else {
 444                additionalStatusInfo = null;
 445            }
 446        } else if (mergedStatus == Message.STATUS_UNSEND) {
 447            final var transferable = message.getTransferable();
 448            if (transferable == null) {
 449                return null;
 450            }
 451            return getContext().getString(R.string.sending_file, transferable.getProgress());
 452        } else {
 453            additionalStatusInfo = null;
 454        }
 455        return additionalStatusInfo;
 456    }
 457
 458    private void displayInfoMessage(
 459            BubbleMessageItemViewHolder viewHolder,
 460            CharSequence text,
 461            final BubbleColor bubbleColor) {
 462        viewHolder.downloadButton().setVisibility(View.GONE);
 463        viewHolder.audioPlayer().setVisibility(View.GONE);
 464        viewHolder.image().setVisibility(View.GONE);
 465        viewHolder.messageBody().setTypeface(null, Typeface.ITALIC);
 466        viewHolder.messageBody().setVisibility(View.VISIBLE);
 467        viewHolder.messageBody().setText(text);
 468        viewHolder
 469                .messageBody()
 470                .setTextColor(bubbleToOnSurfaceVariant(viewHolder.messageBody(), bubbleColor));
 471        viewHolder.messageBody().setTextIsSelectable(false);
 472    }
 473
 474    private void displayEmojiMessage(
 475            final BubbleMessageItemViewHolder viewHolder,
 476            final Message message,
 477            final BubbleColor bubbleColor) {
 478        displayTextMessage(viewHolder, message, bubbleColor);
 479        viewHolder.downloadButton().setVisibility(View.GONE);
 480        viewHolder.audioPlayer().setVisibility(View.GONE);
 481        viewHolder.image().setVisibility(View.GONE);
 482        viewHolder.messageBody().setTypeface(null, Typeface.NORMAL);
 483        viewHolder.messageBody().setVisibility(View.VISIBLE);
 484        setTextColor(viewHolder.messageBody(), bubbleColor);
 485        final var body = getSpannableBody(message);
 486        ImageSpan[] imageSpans = body.getSpans(0, body.length(), ImageSpan.class);
 487        float size = imageSpans.length == 1 || Emoticons.isEmoji(body.toString()) ? 5.0f : 2.0f;
 488        body.setSpan(
 489                new RelativeSizeSpan(size), 0, body.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 490        viewHolder.messageBody().setText(body);
 491    }
 492
 493    private void applyQuoteSpan(
 494            final TextView textView,
 495            Editable body,
 496            int start,
 497            int end,
 498            final BubbleColor bubbleColor,
 499            final boolean makeEdits) {
 500        if (makeEdits && start > 1 && !"\n\n".equals(body.subSequence(start - 2, start).toString())) {
 501            body.insert(start++, "\n");
 502            body.setSpan(
 503                    new DividerSpan(false), start - 2, start, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 504            end++;
 505        }
 506        if (makeEdits && end < body.length() - 1 && !"\n\n".equals(body.subSequence(end, end + 2).toString())) {
 507            body.insert(end, "\n");
 508            body.setSpan(
 509                new DividerSpan(false),
 510                end,
 511                end + ("\n".equals(body.subSequence(end + 1, end + 2).toString()) ? 2 : 1),
 512                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
 513            );
 514        }
 515        final DisplayMetrics metrics = getContext().getResources().getDisplayMetrics();
 516        body.setSpan(
 517                new QuoteSpan(bubbleToOnSurfaceVariant(textView, bubbleColor), metrics),
 518                start,
 519                end,
 520                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 521    }
 522
 523    public boolean handleTextQuotes(final TextView textView, final Editable body) {
 524        return handleTextQuotes(textView, body, true);
 525    }
 526
 527    public boolean handleTextQuotes(final TextView textView, final Editable body, final boolean deleteMarkers) {
 528        final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
 529        final BubbleColor bubbleColor = colorfulBackground ? (deleteMarkers ? BubbleColor.SECONDARY : BubbleColor.TERTIARY) : BubbleColor.SURFACE;
 530        return handleTextQuotes(textView, body, bubbleColor, deleteMarkers);
 531    }
 532
 533    /**
 534     * Applies QuoteSpan to group of lines which starts with > or » characters. Appends likebreaks
 535     * and applies DividerSpan to them to show a padding between quote and text.
 536     */
 537    public boolean handleTextQuotes(
 538            final TextView textView,
 539            final Editable body,
 540            final BubbleColor bubbleColor,
 541            final boolean deleteMarkers) {
 542        boolean startsWithQuote = false;
 543        int quoteDepth = 1;
 544        while (QuoteHelper.bodyContainsQuoteStart(body) && quoteDepth <= Config.QUOTE_MAX_DEPTH) {
 545            char previous = '\n';
 546            int lineStart = -1;
 547            int lineTextStart = -1;
 548            int quoteStart = -1;
 549            int skipped = 0;
 550            for (int i = 0; i <= body.length(); i++) {
 551                if (!deleteMarkers && QuoteHelper.isRelativeSizeSpanned(body, i)) {
 552                    skipped++;
 553                    continue;
 554                }
 555                char current = body.length() > i ? body.charAt(i) : '\n';
 556                if (lineStart == -1) {
 557                    if (previous == '\n') {
 558                        if (i < body.length() && QuoteHelper.isPositionQuoteStart(body, i)) {
 559                            // Line start with quote
 560                            lineStart = i;
 561                            if (quoteStart == -1) quoteStart = i - skipped;
 562                            if (i == 0) startsWithQuote = true;
 563                        } else if (quoteStart >= 0) {
 564                            // Line start without quote, apply spans there
 565                            applyQuoteSpan(textView, body, quoteStart, i - 1, bubbleColor, deleteMarkers);
 566                            quoteStart = -1;
 567                        }
 568                    }
 569                } else {
 570                    // Remove extra spaces between > and first character in the line
 571                    // > character will be removed too
 572                    if (current != ' ' && lineTextStart == -1) {
 573                        lineTextStart = i;
 574                    }
 575                    if (current == '\n') {
 576                        if (deleteMarkers) {
 577                            i -= lineTextStart - lineStart;
 578                            body.delete(lineStart, lineTextStart);
 579                            if (i == lineStart) {
 580                                // Avoid empty lines because span over empty line can be hidden
 581                                body.insert(i++, " ");
 582                            }
 583                        } else {
 584                            body.setSpan(new RelativeSizeSpan(i - (lineTextStart - lineStart) == lineStart ? 1 : 0), lineStart, lineTextStart, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE | StylingHelper.XHTML_REMOVE << Spanned.SPAN_USER_SHIFT);
 585                        }
 586                        lineStart = -1;
 587                        lineTextStart = -1;
 588                    }
 589                }
 590                previous = current;
 591                skipped = 0;
 592            }
 593            if (quoteStart >= 0) {
 594                // Apply spans to finishing open quote
 595                applyQuoteSpan(textView, body, quoteStart, body.length(), bubbleColor, deleteMarkers);
 596            }
 597            quoteDepth++;
 598        }
 599        return startsWithQuote;
 600    }
 601
 602    private SpannableStringBuilder getSpannableBody(final Message message) {
 603        Drawable fallbackImg = ResourcesCompat.getDrawable(activity.getResources(), R.drawable.ic_photo_24dp, null);
 604        return message.getSpannableBody(new Thumbnailer(message), fallbackImg);
 605    }
 606
 607    private void displayTextMessage(
 608            final BubbleMessageItemViewHolder viewHolder,
 609            final Message message,
 610            final BubbleColor bubbleColor) {
 611        viewHolder.inReplyToQuote().setVisibility(View.GONE);
 612        viewHolder.downloadButton().setVisibility(View.GONE);
 613        viewHolder.image().setVisibility(View.GONE);
 614        viewHolder.audioPlayer().setVisibility(View.GONE);
 615        viewHolder.messageBody().setVisibility(View.VISIBLE);
 616        setTextColor(viewHolder.messageBody(), bubbleColor);
 617        setTextSize(viewHolder.messageBody(), this.bubbleDesign.largeFont);
 618        viewHolder.messageBody().setTypeface(null, Typeface.NORMAL);
 619
 620        final ViewGroup.LayoutParams layoutParams = viewHolder.messageBody().getLayoutParams();
 621        layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT;
 622        viewHolder.messageBody().setLayoutParams(layoutParams);
 623
 624        final ViewGroup.LayoutParams qlayoutParams = viewHolder.inReplyToQuote().getLayoutParams();
 625        qlayoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT;
 626        viewHolder.inReplyToQuote().setLayoutParams(qlayoutParams);
 627
 628        final var rawBody = message.getBody();
 629        if (Strings.isNullOrEmpty(rawBody)) {
 630            viewHolder.messageBody().setText("");
 631            viewHolder.messageBody().setTextIsSelectable(false);
 632            toggleWhisperInfo(viewHolder, message, bubbleColor);
 633            return;
 634        }
 635        viewHolder.messageBody().setTextIsSelectable(true);
 636        final String nick = UIHelper.getMessageDisplayName(message);
 637        SpannableStringBuilder body = getSpannableBody(message);
 638        final var processMarkup = body.getSpans(0, body.length(), Message.PlainTextSpan.class).length > 0;
 639        if (body.length() > Config.MAX_DISPLAY_MESSAGE_CHARS) {
 640            body = new SpannableStringBuilder(body, 0, Config.MAX_DISPLAY_MESSAGE_CHARS);
 641            body.append("");
 642        }
 643        if (processMarkup) StylingHelper.format(body, viewHolder.messageBody().getCurrentTextColor());
 644        MyLinkify.addLinks(body, message.getConversation().getAccount(), message.getConversation().getJid());
 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            viewHolder.inReplyTo().setOnClickListener((v) -> mConversationFragment.jumpTo(message.getInReplyTo()));
1520            viewHolder.inReplyToQuote().setOnClickListener((v) -> mConversationFragment.jumpTo(message.getInReplyTo()));
1521            setTextColor(viewHolder.inReplyTo(), bubbleColor);
1522        }
1523
1524        if (appSettings.showLinkPreviews()) {
1525            final var descriptions = message.getLinkDescriptions();
1526            viewHolder.linkDescriptions().setAdapter(new ArrayAdapter<>(activity, 0, descriptions) {
1527                @Override
1528                public View getView(int position, View view, @NonNull ViewGroup parent) {
1529                    final LinkDescriptionBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()), R.layout.link_description, parent, false);
1530                    binding.title.setText(getItem(position).findChildContent("title", "https://ogp.me/ns#"));
1531                    binding.description.setText(getItem(position).findChildContent("description", "https://ogp.me/ns#"));
1532                    binding.url.setText(getItem(position).findChildContent("url", "https://ogp.me/ns#"));
1533                    final var video = getItem(position).findChildContent("video", "https://ogp.me/ns#");
1534                    if (video != null && video.length() > 0) {
1535                        binding.playButton.setVisibility(View.VISIBLE);
1536                        binding.playButton.setOnClickListener((v) -> {
1537                            new FixedURLSpan(video).onClick(v);
1538                        });
1539                    }
1540                    return binding.getRoot();
1541                }
1542            });
1543            Util.justifyListViewHeightBasedOnChildren(viewHolder.linkDescriptions(), (int)(metrics.density * 100), true);
1544        }
1545
1546        displayStatus(viewHolder, message, bubbleColor);
1547
1548       viewHolder.messageBody().setAccessibilityDelegate(new View.AccessibilityDelegate() {
1549            @Override
1550            public void sendAccessibilityEvent(View host, int eventType) {
1551                super.sendAccessibilityEvent(host, eventType);
1552                if (eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) {
1553                    if (viewHolder.messageBody().hasSelection()) {
1554                        selectionUuid = message.getUuid();
1555                    } else if (message.getUuid() != null && message.getUuid().equals(selectionUuid)) {
1556                        selectionUuid = null;
1557                    }
1558                }
1559            }
1560        });
1561
1562        return viewHolder.root();
1563    }
1564
1565    private View render(
1566            final Message message, final DateSeperatorMessageItemViewHolder viewHolder) {
1567        final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
1568        if (UIHelper.today(message.getTimeSent())) {
1569            viewHolder.binding.messageBody.setText(R.string.today);
1570        } else if (UIHelper.yesterday(message.getTimeSent())) {
1571            viewHolder.binding.messageBody.setText(R.string.yesterday);
1572        } else {
1573            viewHolder.binding.messageBody.setText(
1574                    DateUtils.formatDateTime(
1575                            activity,
1576                            message.getTimeSent(),
1577                            DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR));
1578        }
1579        if (colorfulBackground) {
1580            setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.PRIMARY);
1581            setTextColor(viewHolder.binding.messageBody, BubbleColor.PRIMARY);
1582        } else {
1583            setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.SURFACE_HIGH);
1584            setTextColor(viewHolder.binding.messageBody, BubbleColor.SURFACE_HIGH);
1585        }
1586        return viewHolder.binding.getRoot();
1587    }
1588
1589    private View render(final Message message, final RtpSessionMessageItemViewHolder viewHolder) {
1590        final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
1591        final boolean received = message.getStatus() <= Message.STATUS_RECEIVED;
1592        final RtpSessionStatus rtpSessionStatus = RtpSessionStatus.of(message.getBody());
1593        final long duration = rtpSessionStatus.duration;
1594        if (received) {
1595            if (duration > 0) {
1596                viewHolder.binding.messageBody.setText(
1597                        activity.getString(
1598                                R.string.incoming_call_duration_timestamp,
1599                                TimeFrameUtils.resolve(activity, duration),
1600                                UIHelper.readableTimeDifferenceFull(
1601                                        activity, message.getTimeSent())));
1602            } else if (rtpSessionStatus.successful) {
1603                viewHolder.binding.messageBody.setText(R.string.incoming_call);
1604            } else {
1605                viewHolder.binding.messageBody.setText(
1606                        activity.getString(
1607                                R.string.missed_call_timestamp,
1608                                UIHelper.readableTimeDifferenceFull(
1609                                        activity, message.getTimeSent())));
1610            }
1611        } else {
1612            if (duration > 0) {
1613                viewHolder.binding.messageBody.setText(
1614                        activity.getString(
1615                                R.string.outgoing_call_duration_timestamp,
1616                                TimeFrameUtils.resolve(activity, duration),
1617                                UIHelper.readableTimeDifferenceFull(
1618                                        activity, message.getTimeSent())));
1619            } else {
1620                viewHolder.binding.messageBody.setText(
1621                        activity.getString(
1622                                R.string.outgoing_call_timestamp,
1623                                UIHelper.readableTimeDifferenceFull(
1624                                        activity, message.getTimeSent())));
1625            }
1626        }
1627        if (colorfulBackground) {
1628            setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.SECONDARY);
1629            setTextColor(viewHolder.binding.messageBody, BubbleColor.SECONDARY);
1630            setImageTint(viewHolder.binding.indicatorReceived, BubbleColor.SECONDARY);
1631        } else {
1632            setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.SURFACE_HIGH);
1633            setTextColor(viewHolder.binding.messageBody, BubbleColor.SURFACE_HIGH);
1634            setImageTint(viewHolder.binding.indicatorReceived, BubbleColor.SURFACE_HIGH);
1635        }
1636        viewHolder.binding.indicatorReceived.setImageResource(
1637                RtpSessionStatus.getDrawable(received, rtpSessionStatus.successful));
1638        return viewHolder.binding.getRoot();
1639    }
1640
1641    private View render(final Message message, final StatusMessageItemViewHolder viewHolder) {
1642        final var conversation = message.getConversation();
1643        if ("LOAD_MORE".equals(message.getBody())) {
1644            viewHolder.binding.statusMessage.setVisibility(View.GONE);
1645            viewHolder.binding.messagePhoto.setVisibility(View.GONE);
1646            viewHolder.binding.loadMoreMessages.setVisibility(View.VISIBLE);
1647            viewHolder.binding.loadMoreMessages.setOnClickListener(
1648                    v -> loadMoreMessages((Conversation) message.getConversation()));
1649        } else {
1650            viewHolder.binding.statusMessage.setVisibility(View.VISIBLE);
1651            viewHolder.binding.loadMoreMessages.setVisibility(View.GONE);
1652            viewHolder.binding.statusMessage.setText(message.getBody());
1653            boolean showAvatar;
1654            if (conversation.getMode() == Conversation.MODE_SINGLE) {
1655                showAvatar = true;
1656                AvatarWorkerTask.loadAvatar(
1657                        message, viewHolder.binding.messagePhoto, R.dimen.avatar_on_status_message);
1658            } else if (message.getCounterpart() != null
1659                    || message.getTrueCounterpart() != null
1660                    || (message.getCounterparts() != null
1661                            && !message.getCounterparts().isEmpty())) {
1662                showAvatar = true;
1663                AvatarWorkerTask.loadAvatar(
1664                        message, viewHolder.binding.messagePhoto, R.dimen.avatar_on_status_message);
1665            } else {
1666                showAvatar = false;
1667            }
1668            if (showAvatar) {
1669                viewHolder.binding.messagePhoto.setAlpha(0.5f);
1670                viewHolder.binding.messagePhoto.setVisibility(View.VISIBLE);
1671            } else {
1672                viewHolder.binding.messagePhoto.setVisibility(View.GONE);
1673            }
1674        }
1675        return viewHolder.binding.getRoot();
1676    }
1677
1678    private void setAvatarDistance(
1679            final LinearLayout messageBox,
1680            final Class<? extends BubbleMessageItemViewHolder> clazz,
1681            final boolean showAvatar) {
1682        final ViewGroup.MarginLayoutParams layoutParams =
1683                (ViewGroup.MarginLayoutParams) messageBox.getLayoutParams();
1684        if (false) { // no need for space since the shape has space inside it for tails
1685            final var resources = messageBox.getResources();
1686            if (clazz == StartBubbleMessageItemViewHolder.class) {
1687                layoutParams.setMarginStart(
1688                        resources.getDimensionPixelSize(R.dimen.bubble_avatar_distance));
1689                layoutParams.setMarginEnd(0);
1690            } else if (clazz == EndBubbleMessageItemViewHolder.class) {
1691                layoutParams.setMarginStart(0);
1692                layoutParams.setMarginEnd(
1693                        resources.getDimensionPixelSize(R.dimen.bubble_avatar_distance));
1694            } else {
1695                throw new AssertionError("Avatar distances are not available on this view type");
1696            }
1697        } else {
1698            layoutParams.setMarginStart(0);
1699            layoutParams.setMarginEnd(0);
1700        }
1701        messageBox.setLayoutParams(layoutParams);
1702    }
1703
1704    private void setBubblePadding(
1705            final ConstraintLayout root,
1706            final boolean mergeIntoTop,
1707            final boolean mergeIntoBottom) {
1708        final var resources = root.getResources();
1709        final var horizontal = resources.getDimensionPixelSize(R.dimen.bubble_horizontal_padding);
1710        final int top =
1711                resources.getDimensionPixelSize(
1712                        mergeIntoTop
1713                                ? R.dimen.bubble_vertical_padding_minimum
1714                                : R.dimen.bubble_vertical_padding);
1715        final int bottom =
1716                resources.getDimensionPixelSize(
1717                        mergeIntoBottom
1718                                ? R.dimen.bubble_vertical_padding_minimum
1719                                : R.dimen.bubble_vertical_padding);
1720        root.setPadding(horizontal, top, horizontal, bottom);
1721    }
1722
1723    private void setRequiresAvatar(
1724            final BubbleMessageItemViewHolder viewHolder, final boolean requiresAvatar) {
1725        final var layoutParams = viewHolder.contactPicture().getLayoutParams();
1726        if (requiresAvatar) {
1727            final var resources = viewHolder.contactPicture().getResources();
1728            final var avatarSize = resources.getDimensionPixelSize(R.dimen.bubble_avatar_size);
1729            layoutParams.height = avatarSize;
1730            viewHolder.contactPicture().setVisibility(View.VISIBLE);
1731            viewHolder.messageBox().setMinimumHeight(avatarSize);
1732        } else {
1733            layoutParams.height = 0;
1734            viewHolder.contactPicture().setVisibility(View.INVISIBLE);
1735            viewHolder.messageBox().setMinimumHeight(0);
1736        }
1737        viewHolder.contactPicture().setLayoutParams(layoutParams);
1738    }
1739
1740    private boolean mergeIntoTop(final int position, final Message message) {
1741        if (position < 0) {
1742            return false;
1743        }
1744        final var top = getItem(position - 1);
1745        return merge(top, message);
1746    }
1747
1748    private boolean mergeIntoBottom(final int position, final Message message) {
1749        final Message bottom;
1750        try {
1751            bottom = getItem(position + 1);
1752        } catch (final IndexOutOfBoundsException e) {
1753            return false;
1754        }
1755        return merge(message, bottom);
1756    }
1757
1758    private static boolean merge(final Message a, final Message b) {
1759        if (getItemViewType(a, false) != getItemViewType(b, false)) {
1760            return false;
1761        }
1762        final var receivedA = a.getStatus() == Message.STATUS_RECEIVED;
1763        final var receivedB = b.getStatus() == Message.STATUS_RECEIVED;
1764        if (receivedA != receivedB) {
1765            return false;
1766        }
1767        if (a.getConversation().getMode() == Conversation.MODE_MULTI
1768                && a.getStatus() == Message.STATUS_RECEIVED) {
1769            final var occupantIdA = a.getOccupantId();
1770            final var occupantIdB = b.getOccupantId();
1771            if (occupantIdA != null && occupantIdB != null) {
1772                if (!occupantIdA.equals(occupantIdB)) {
1773                    return false;
1774                }
1775            }
1776            final var counterPartA = a.getCounterpart();
1777            final var counterPartB = b.getCounterpart();
1778            if (counterPartA == null || !counterPartA.equals(counterPartB)) {
1779                return false;
1780            }
1781        }
1782        return b.getTimeSent() - a.getTimeSent() <= Config.MESSAGE_MERGE_WINDOW;
1783    }
1784
1785    private boolean showDetailedReaction(final Message message, Map.Entry<EmojiSearch.Emoji, Collection<Reaction>> reaction) {
1786        final var c = message.getConversation();
1787        if (c instanceof Conversation conversation && c.getMode() == Conversational.MODE_MULTI) {
1788            final var reactions = reaction.getValue();
1789            final var mucOptions = conversation.getMucOptions();
1790            final var users = mucOptions.findUsers(reactions);
1791            if (users.isEmpty()) {
1792                return true;
1793            }
1794            final MaterialAlertDialogBuilder dialogBuilder =
1795                    new MaterialAlertDialogBuilder(activity);
1796            dialogBuilder.setTitle(reaction.getKey().toString());
1797            dialogBuilder.setMessage(UIHelper.concatNames(users));
1798            dialogBuilder.create().show();
1799            return true;
1800        } else {
1801            return false;
1802        }
1803    }
1804
1805    private void sendReactions(final Message message, final Collection<String> reactions) {
1806        if (!message.isPrivateMessage() && activity.xmppConnectionService.sendReactions(message, reactions)) {
1807            return;
1808        }
1809        Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
1810    }
1811
1812    private void sendCustomReaction(final Message inReplyTo, final EmojiSearch.CustomEmoji emoji) {
1813        final var message = inReplyTo.reply();
1814        message.appendBody(emoji.toInsert());
1815        Message.configurePrivateMessage(message);
1816        new Thread(() -> activity.xmppConnectionService.sendMessage(message)).start();
1817    }
1818
1819    private void removeCustomReaction(final Conversational conversation, final Reaction reaction) {
1820        if (!(conversation instanceof Conversation)) {
1821            Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
1822            return;
1823        }
1824
1825        final var message = new Message(conversation, " ", ((Conversation) conversation).getNextEncryption());
1826        final var envelope = ((Conversation) conversation).findMessageWithUuidOrRemoteId(reaction.envelopeId);
1827        if (envelope != null) {
1828            ((Conversation) conversation).remove(envelope);
1829            message.addPayload(envelope.getReply());
1830            message.getOrMakeHtml();
1831            message.putEdited(reaction.envelopeId, envelope.getServerMsgId());
1832        } else {
1833            message.putEdited(reaction.envelopeId, null);
1834        }
1835
1836        new Thread(() -> activity.xmppConnectionService.sendMessage(message)).start();
1837    }
1838
1839    private void addReaction(final Message message) {
1840        activity.addReaction(
1841                message,
1842                reactions -> {
1843                    if (activity.xmppConnectionService.sendReactions(message, reactions)) {
1844                        return;
1845                    }
1846                    Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG)
1847                            .show();
1848                });
1849    }
1850
1851    private void promptOpenKeychainInstall(View view) {
1852        activity.showInstallPgpDialog();
1853    }
1854
1855    public FileBackend getFileBackend() {
1856        return activity.xmppConnectionService.getFileBackend();
1857    }
1858
1859    public void stopAudioPlayer() {
1860        audioPlayer.stop();
1861    }
1862
1863    public void unregisterListenerInAudioPlayer() {
1864        audioPlayer.unregisterListener();
1865    }
1866
1867    public void startStopPending() {
1868        audioPlayer.startStopPending();
1869    }
1870
1871    public void openDownloadable(Message message) {
1872        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
1873                && ContextCompat.checkSelfPermission(
1874                                activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)
1875                        != PackageManager.PERMISSION_GRANTED) {
1876            ConversationFragment.registerPendingMessage(activity, message);
1877            ActivityCompat.requestPermissions(
1878                    activity,
1879                    new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE},
1880                    ConversationsActivity.REQUEST_OPEN_MESSAGE);
1881            return;
1882        }
1883        final DownloadableFile file =
1884                activity.xmppConnectionService.getFileBackend().getFile(message);
1885        final var fp = message.getFileParams();
1886        final var name = fp == null ? null : fp.getName();
1887        final var displayName = name == null ? file.getName() : name;
1888        ViewUtil.view(activity, file, displayName);
1889    }
1890
1891    private void showLocation(Message message) {
1892        for (Intent intent : GeoHelper.createGeoIntentsFromMessage(activity, message)) {
1893            if (intent.resolveActivity(getContext().getPackageManager()) != null) {
1894                getContext().startActivity(intent);
1895                return;
1896            }
1897        }
1898        Toast.makeText(
1899                        activity,
1900                        R.string.no_application_found_to_display_location,
1901                        Toast.LENGTH_SHORT)
1902                .show();
1903    }
1904
1905    public void updatePreferences() {
1906        this.bubbleDesign =
1907                new BubbleDesign(
1908                        appSettings.isColorfulChatBubbles(),
1909                        appSettings.isAlignStart(),
1910                        appSettings.isLargeFont(),
1911                        appSettings.isShowAvatars());
1912    }
1913
1914    public void setHighlightedTerm(List<String> terms) {
1915        this.highlightedTerm = terms == null ? null : StylingHelper.filterHighlightedWords(terms);
1916    }
1917
1918    public interface OnContactPictureClicked {
1919        void onContactPictureClicked(Message message);
1920    }
1921
1922    public interface OnContactPictureLongClicked {
1923        void onContactPictureLongClicked(View v, Message message);
1924    }
1925
1926    public interface OnInlineImageLongClicked {
1927        boolean onInlineImageLongClicked(Cid cid);
1928    }
1929
1930    private static void setBackgroundTint(final LinearLayout view, final BubbleColor bubbleColor) {
1931        view.setBackgroundTintList(bubbleToColorStateList(view, bubbleColor));
1932    }
1933
1934    private static ColorStateList bubbleToColorStateList(
1935            final View view, final BubbleColor bubbleColor) {
1936        final @AttrRes int colorAttributeResId =
1937                switch (bubbleColor) {
1938                    case SURFACE ->
1939                            Activities.isNightMode(view.getContext())
1940                                    ? com.google.android.material.R.attr.colorSurfaceContainerHigh
1941                                    : com.google.android.material.R.attr.colorSurfaceContainerLow;
1942                    case SURFACE_HIGH ->
1943                            Activities.isNightMode(view.getContext())
1944                                    ? com.google.android.material.R.attr
1945                                            .colorSurfaceContainerHighest
1946                                    : com.google.android.material.R.attr.colorSurfaceContainerHigh;
1947                    case PRIMARY -> com.google.android.material.R.attr.colorPrimaryContainer;
1948                    case SECONDARY -> com.google.android.material.R.attr.colorSecondaryContainer;
1949                    case TERTIARY -> com.google.android.material.R.attr.colorTertiaryContainer;
1950                    case WARNING -> com.google.android.material.R.attr.colorErrorContainer;
1951                };
1952        return ColorStateList.valueOf(MaterialColors.getColor(view, colorAttributeResId));
1953    }
1954
1955    public static void setImageTint(final ImageView imageView, final BubbleColor bubbleColor) {
1956        ImageViewCompat.setImageTintList(
1957                imageView, bubbleToOnSurfaceColorStateList(imageView, bubbleColor));
1958    }
1959
1960    public static void setImageTintError(final ImageView imageView) {
1961        ImageViewCompat.setImageTintList(
1962                imageView,
1963                ColorStateList.valueOf(
1964                        MaterialColors.getColor(
1965                                imageView, com.google.android.material.R.attr.colorError)));
1966    }
1967
1968    public static void setTextColor(final TextView textView, final BubbleColor bubbleColor) {
1969        final var color = bubbleToOnSurfaceColor(textView, bubbleColor);
1970        textView.setTextColor(color);
1971        if (BubbleColor.SURFACES.contains(bubbleColor)) {
1972            textView.setLinkTextColor(
1973                    MaterialColors.getColor(
1974                            textView, com.google.android.material.R.attr.colorPrimary));
1975        } else {
1976            textView.setLinkTextColor(color);
1977        }
1978    }
1979
1980    private static void setTextSize(final TextView textView, final boolean largeFont) {
1981        if (largeFont) {
1982            textView.setTextAppearance(
1983                    com.google.android.material.R.style.TextAppearance_Material3_BodyLarge);
1984            textView.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 18);
1985        } else {
1986            textView.setTextAppearance(
1987                    com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
1988        }
1989    }
1990
1991    private static @ColorInt int bubbleToOnSurfaceVariant(
1992            final View view, final BubbleColor bubbleColor) {
1993        final @AttrRes int colorAttributeResId;
1994        if (BubbleColor.SURFACES.contains(bubbleColor)) {
1995            colorAttributeResId = com.google.android.material.R.attr.colorOnSurfaceVariant;
1996        } else {
1997            colorAttributeResId = bubbleToOnSurface(bubbleColor);
1998        }
1999        return MaterialColors.getColor(view, colorAttributeResId);
2000    }
2001
2002    private static @ColorInt int bubbleToOnSurfaceColor(
2003            final View view, final BubbleColor bubbleColor) {
2004        return MaterialColors.getColor(view, bubbleToOnSurface(bubbleColor));
2005    }
2006
2007    public static ColorStateList bubbleToOnSurfaceColorStateList(
2008            final View view, final BubbleColor bubbleColor) {
2009        return ColorStateList.valueOf(bubbleToOnSurfaceColor(view, bubbleColor));
2010    }
2011
2012    private static @AttrRes int bubbleToOnSurface(final BubbleColor bubbleColor) {
2013        return switch (bubbleColor) {
2014            case SURFACE, SURFACE_HIGH -> com.google.android.material.R.attr.colorOnSurface;
2015            case PRIMARY -> com.google.android.material.R.attr.colorOnPrimaryContainer;
2016            case SECONDARY -> com.google.android.material.R.attr.colorOnSecondaryContainer;
2017            case TERTIARY -> com.google.android.material.R.attr.colorOnTertiaryContainer;
2018            case WARNING -> com.google.android.material.R.attr.colorOnErrorContainer;
2019        };
2020    }
2021
2022    public enum BubbleColor {
2023        SURFACE,
2024        SURFACE_HIGH,
2025        PRIMARY,
2026        SECONDARY,
2027        TERTIARY,
2028        WARNING;
2029
2030        private static final Collection<BubbleColor> SURFACES =
2031                Arrays.asList(BubbleColor.SURFACE, BubbleColor.SURFACE_HIGH);
2032    }
2033
2034    private static class BubbleDesign {
2035        public final boolean colorfulChatBubbles;
2036        public final boolean alignStart;
2037        public final boolean largeFont;
2038        public final boolean showAvatars;
2039
2040        private BubbleDesign(
2041                final boolean colorfulChatBubbles,
2042                final boolean alignStart,
2043                final boolean largeFont,
2044                final boolean showAvatars) {
2045            this.colorfulChatBubbles = colorfulChatBubbles;
2046            this.alignStart = alignStart;
2047            this.largeFont = largeFont;
2048            this.showAvatars = showAvatars;
2049        }
2050    }
2051
2052    private abstract static class MessageItemViewHolder /*extends RecyclerView.ViewHolder*/ {
2053
2054        private View itemView;
2055
2056        private MessageItemViewHolder(@NonNull View itemView) {
2057            this.itemView = itemView;
2058        }
2059    }
2060
2061    private abstract static class BubbleMessageItemViewHolder extends MessageItemViewHolder {
2062
2063        private BubbleMessageItemViewHolder(@NonNull View itemView) {
2064            super(itemView);
2065        }
2066
2067        public abstract ConstraintLayout root();
2068
2069        protected abstract ImageView indicatorEdit();
2070
2071        protected abstract RelativeLayout audioPlayer();
2072
2073        protected abstract LinearLayout messageBox();
2074
2075        protected abstract MaterialButton downloadButton();
2076
2077        protected abstract ShapeableImageView image();
2078
2079        protected abstract ImageView indicatorSecurity();
2080
2081        protected abstract ImageView indicatorReceived();
2082
2083        protected abstract TextView time();
2084
2085        protected abstract TextView messageBody();
2086
2087        protected abstract ImageView contactPicture();
2088
2089        protected abstract ChipGroup reactions();
2090
2091        protected abstract ListView commandsList();
2092
2093        protected abstract View messageBoxInner();
2094
2095        protected abstract View statusLine();
2096
2097        protected abstract GithubIdenticonView threadIdenticon();
2098
2099        protected abstract ListView linkDescriptions();
2100
2101        protected abstract LinearLayout inReplyToBox();
2102
2103        protected abstract TextView inReplyTo();
2104
2105        protected abstract TextView inReplyToQuote();
2106
2107        protected abstract TextView subject();
2108    }
2109
2110    private static class StartBubbleMessageItemViewHolder extends BubbleMessageItemViewHolder {
2111
2112        private final ItemMessageStartBinding binding;
2113
2114        public StartBubbleMessageItemViewHolder(@NonNull ItemMessageStartBinding binding) {
2115            super(binding.getRoot());
2116            this.binding = binding;
2117        }
2118
2119        @Override
2120        public ConstraintLayout root() {
2121            return (ConstraintLayout) this.binding.getRoot();
2122        }
2123
2124        @Override
2125        protected ImageView indicatorEdit() {
2126            return this.binding.editIndicator;
2127        }
2128
2129        @Override
2130        protected RelativeLayout audioPlayer() {
2131            return this.binding.messageContent.audioPlayer;
2132        }
2133
2134        @Override
2135        protected LinearLayout messageBox() {
2136            return this.binding.messageBox;
2137        }
2138
2139        @Override
2140        protected MaterialButton downloadButton() {
2141            return this.binding.messageContent.downloadButton;
2142        }
2143
2144        @Override
2145        protected ShapeableImageView image() {
2146            return this.binding.messageContent.messageImage;
2147        }
2148
2149        protected ImageView indicatorSecurity() {
2150            return this.binding.securityIndicator;
2151        }
2152
2153        @Override
2154        protected ImageView indicatorReceived() {
2155            return this.binding.indicatorReceived;
2156        }
2157
2158        @Override
2159        protected TextView time() {
2160            return this.binding.messageTime;
2161        }
2162
2163        @Override
2164        protected TextView messageBody() {
2165            return this.binding.messageContent.messageBody;
2166        }
2167
2168        protected TextView encryption() {
2169            return this.binding.messageEncryption;
2170        }
2171
2172        @Override
2173        protected ImageView contactPicture() {
2174            return this.binding.messagePhoto;
2175        }
2176
2177        @Override
2178        protected ChipGroup reactions() {
2179            return this.binding.reactions;
2180        }
2181
2182        @Override
2183        protected ListView commandsList() {
2184            return this.binding.messageContent.commandsList;
2185        }
2186
2187        @Override
2188        protected View messageBoxInner() {
2189            return this.binding.messageBoxInner;
2190        }
2191
2192        @Override
2193        protected View statusLine() {
2194            return this.binding.statusLine;
2195        }
2196
2197        @Override
2198        protected GithubIdenticonView threadIdenticon() {
2199            return this.binding.threadIdenticon;
2200        }
2201
2202        @Override
2203        protected ListView linkDescriptions() {
2204            return this.binding.messageContent.linkDescriptions;
2205        }
2206
2207        @Override
2208        protected LinearLayout inReplyToBox() {
2209            return this.binding.messageContent.inReplyToBox;
2210        }
2211
2212        @Override
2213        protected TextView inReplyTo() {
2214            return this.binding.messageContent.inReplyTo;
2215        }
2216
2217        @Override
2218        protected TextView inReplyToQuote() {
2219            return this.binding.messageContent.inReplyToQuote;
2220        }
2221
2222        @Override
2223        protected TextView subject() {
2224            return this.binding.messageSubject;
2225        }
2226    }
2227
2228    private static class EndBubbleMessageItemViewHolder extends BubbleMessageItemViewHolder {
2229
2230        private final ItemMessageEndBinding binding;
2231
2232        private EndBubbleMessageItemViewHolder(@NonNull ItemMessageEndBinding binding) {
2233            super(binding.getRoot());
2234            this.binding = binding;
2235        }
2236
2237        @Override
2238        public ConstraintLayout root() {
2239            return (ConstraintLayout) this.binding.getRoot();
2240        }
2241
2242        @Override
2243        protected ImageView indicatorEdit() {
2244            return this.binding.editIndicator;
2245        }
2246
2247        @Override
2248        protected RelativeLayout audioPlayer() {
2249            return this.binding.messageContent.audioPlayer;
2250        }
2251
2252        @Override
2253        protected LinearLayout messageBox() {
2254            return this.binding.messageBox;
2255        }
2256
2257        @Override
2258        protected MaterialButton downloadButton() {
2259            return this.binding.messageContent.downloadButton;
2260        }
2261
2262        @Override
2263        protected ShapeableImageView image() {
2264            return this.binding.messageContent.messageImage;
2265        }
2266
2267        @Override
2268        protected ImageView indicatorSecurity() {
2269            return this.binding.securityIndicator;
2270        }
2271
2272        @Override
2273        protected ImageView indicatorReceived() {
2274            return this.binding.indicatorReceived;
2275        }
2276
2277        @Override
2278        protected TextView time() {
2279            return this.binding.messageTime;
2280        }
2281
2282        @Override
2283        protected TextView messageBody() {
2284            return this.binding.messageContent.messageBody;
2285        }
2286
2287        @Override
2288        protected ImageView contactPicture() {
2289            return this.binding.messagePhoto;
2290        }
2291
2292        @Override
2293        protected ChipGroup reactions() {
2294            return this.binding.reactions;
2295        }
2296
2297        @Override
2298        protected ListView commandsList() {
2299            return this.binding.messageContent.commandsList;
2300        }
2301
2302        @Override
2303        protected View messageBoxInner() {
2304            return this.binding.messageBoxInner;
2305        }
2306
2307        @Override
2308        protected View statusLine() {
2309            return this.binding.statusLine;
2310        }
2311
2312        @Override
2313        protected GithubIdenticonView threadIdenticon() {
2314            return this.binding.threadIdenticon;
2315        }
2316
2317        @Override
2318        protected ListView linkDescriptions() {
2319            return this.binding.messageContent.linkDescriptions;
2320        }
2321
2322        @Override
2323        protected LinearLayout inReplyToBox() {
2324            return this.binding.messageContent.inReplyToBox;
2325        }
2326
2327        @Override
2328        protected TextView inReplyTo() {
2329            return this.binding.messageContent.inReplyTo;
2330        }
2331
2332        @Override
2333        protected TextView inReplyToQuote() {
2334            return this.binding.messageContent.inReplyToQuote;
2335        }
2336
2337        @Override
2338        protected TextView subject() {
2339            return this.binding.messageSubject;
2340        }
2341    }
2342
2343    private static class DateSeperatorMessageItemViewHolder extends MessageItemViewHolder {
2344
2345        private final ItemMessageDateBubbleBinding binding;
2346
2347        private DateSeperatorMessageItemViewHolder(@NonNull ItemMessageDateBubbleBinding binding) {
2348            super(binding.getRoot());
2349            this.binding = binding;
2350        }
2351    }
2352
2353    private static class RtpSessionMessageItemViewHolder extends MessageItemViewHolder {
2354
2355        private final ItemMessageRtpSessionBinding binding;
2356
2357        private RtpSessionMessageItemViewHolder(@NonNull ItemMessageRtpSessionBinding binding) {
2358            super(binding.getRoot());
2359            this.binding = binding;
2360        }
2361    }
2362
2363    private static class StatusMessageItemViewHolder extends MessageItemViewHolder {
2364
2365        private final ItemMessageStatusBinding binding;
2366
2367        private StatusMessageItemViewHolder(@NonNull ItemMessageStatusBinding binding) {
2368            super(binding.getRoot());
2369            this.binding = binding;
2370        }
2371    }
2372
2373    class Thumbnailer implements GetThumbnailForCid {
2374        final Account account;
2375        final boolean canFetch;
2376        final Jid counterpart;
2377
2378        public Thumbnailer(final Message message) {
2379            account = message.getConversation().getAccount();
2380            canFetch = message.trusted() || message.getConversation().canInferPresence();
2381            counterpart = message.getCounterpart();
2382        }
2383
2384        public Thumbnailer(final Account account, final Reaction reaction, final boolean allowFetch) {
2385            canFetch = allowFetch;
2386            counterpart = reaction.from;
2387            this.account = account;
2388        }
2389
2390        @Override
2391        public Drawable getThumbnail(Cid cid) {
2392            try {
2393                DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
2394                if (f == null || !f.canRead()) {
2395                    if (!canFetch) return null;
2396
2397                    try {
2398                        new BobTransfer(BobTransfer.uri(cid), account, counterpart, activity.xmppConnectionService).start();
2399                    } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
2400                    return null;
2401                }
2402
2403                Drawable d = activity.xmppConnectionService.getFileBackend().getThumbnail(f, activity.getResources(), (int) (metrics.density * 288), true);
2404                if (d == null) {
2405                    new ThumbnailTask().execute(f);
2406                }
2407                return d;
2408            } catch (final IOException e) {
2409                return null;
2410            }
2411        }
2412    }
2413
2414    class ThumbnailTask extends AsyncTask<DownloadableFile, Void, Drawable[]> {
2415        @Override
2416        protected Drawable[] doInBackground(DownloadableFile... params) {
2417            if (isCancelled()) return null;
2418
2419            Drawable[] d = new Drawable[params.length];
2420            for (int i = 0; i < params.length; i++) {
2421                try {
2422                    d[i] = activity.xmppConnectionService.getFileBackend().getThumbnail(params[i], activity.getResources(), (int) (metrics.density * 288), false);
2423                } catch (final IOException e) {
2424                    d[i] = null;
2425                }
2426            }
2427
2428            return d;
2429        }
2430
2431        @Override
2432        protected void onPostExecute(final Drawable[] d) {
2433            if (isCancelled()) return;
2434            activity.xmppConnectionService.updateConversationUi();
2435        }
2436    }
2437}