MessageAdapter.java

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