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