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