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.Spannable;
  16import android.text.SpannableString;
  17import android.text.SpannableStringBuilder;
  18import android.text.style.ImageSpan;
  19import android.text.style.ClickableSpan;
  20import android.text.format.DateUtils;
  21import android.text.style.ForegroundColorSpan;
  22import android.text.style.RelativeSizeSpan;
  23import android.text.style.StyleSpan;
  24import android.text.style.URLSpan;
  25import android.util.DisplayMetrics;
  26import android.util.LruCache;
  27import android.view.accessibility.AccessibilityEvent;
  28import android.view.Gravity;
  29import android.view.MotionEvent;
  30import android.view.View;
  31import android.view.ViewGroup;
  32import android.view.WindowManager;
  33import android.widget.ArrayAdapter;
  34import android.widget.ImageView;
  35import android.widget.LinearLayout;
  36import android.widget.ListAdapter;
  37import android.widget.ListView;
  38import android.widget.RelativeLayout;
  39import android.widget.TextView;
  40import android.widget.Toast;
  41
  42import androidx.annotation.AttrRes;
  43import androidx.annotation.ColorInt;
  44import androidx.annotation.DrawableRes;
  45import androidx.annotation.NonNull;
  46import androidx.annotation.Nullable;
  47import androidx.core.app.ActivityCompat;
  48import androidx.core.content.ContextCompat;
  49import androidx.core.content.res.ResourcesCompat;
  50import androidx.core.widget.ImageViewCompat;
  51
  52import com.google.android.material.imageview.ShapeableImageView;
  53import com.google.android.material.shape.CornerFamily;
  54import com.google.android.material.shape.ShapeAppearanceModel;
  55
  56import com.cheogram.android.BobTransfer;
  57import com.cheogram.android.MessageTextActionModeCallback;
  58import com.cheogram.android.SwipeDetector;
  59import com.cheogram.android.WebxdcPage;
  60import com.cheogram.android.WebxdcUpdate;
  61
  62import com.google.android.material.button.MaterialButton;
  63import com.google.android.material.color.MaterialColors;
  64import com.google.common.base.Joiner;
  65import com.google.common.base.Strings;
  66import com.google.common.collect.ImmutableList;
  67
  68import com.lelloman.identicon.view.GithubIdenticonView;
  69
  70import java.io.IOException;
  71import java.net.URI;
  72import java.net.URISyntaxException;
  73import java.security.NoSuchAlgorithmException;
  74import java.util.HashMap;
  75import java.util.List;
  76import java.util.Map;
  77import java.util.Locale;
  78import java.util.regex.Matcher;
  79import java.util.regex.Pattern;
  80
  81import io.ipfs.cid.Cid;
  82
  83import me.saket.bettermovementmethod.BetterLinkMovementMethod;
  84
  85import eu.siacs.conversations.AppSettings;
  86import eu.siacs.conversations.Config;
  87import eu.siacs.conversations.R;
  88import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
  89import eu.siacs.conversations.entities.Account;
  90import eu.siacs.conversations.entities.Contact;
  91import eu.siacs.conversations.entities.Conversation;
  92import eu.siacs.conversations.entities.Conversational;
  93import eu.siacs.conversations.entities.DownloadableFile;
  94import eu.siacs.conversations.entities.Message.FileParams;
  95import eu.siacs.conversations.entities.Message;
  96import eu.siacs.conversations.entities.MucOptions;
  97import eu.siacs.conversations.entities.Roster;
  98import eu.siacs.conversations.entities.RtpSessionStatus;
  99import eu.siacs.conversations.entities.Transferable;
 100import eu.siacs.conversations.persistance.FileBackend;
 101import eu.siacs.conversations.services.MessageArchiveService;
 102import eu.siacs.conversations.services.NotificationService;
 103import eu.siacs.conversations.ui.Activities;
 104import eu.siacs.conversations.ui.ConversationFragment;
 105import eu.siacs.conversations.ui.ConversationsActivity;
 106import eu.siacs.conversations.ui.XmppActivity;
 107import eu.siacs.conversations.ui.service.AudioPlayer;
 108import eu.siacs.conversations.ui.text.DividerSpan;
 109import eu.siacs.conversations.ui.text.QuoteSpan;
 110import eu.siacs.conversations.ui.util.Attachment;
 111import eu.siacs.conversations.ui.util.AvatarWorkerTask;
 112import eu.siacs.conversations.ui.util.MyLinkify;
 113import eu.siacs.conversations.ui.util.QuoteHelper;
 114import eu.siacs.conversations.ui.util.ShareUtil;
 115import eu.siacs.conversations.ui.util.ViewUtil;
 116import eu.siacs.conversations.utils.CryptoHelper;
 117import eu.siacs.conversations.utils.Emoticons;
 118import eu.siacs.conversations.utils.GeoHelper;
 119import eu.siacs.conversations.utils.MessageUtils;
 120import eu.siacs.conversations.utils.StylingHelper;
 121import eu.siacs.conversations.utils.TimeFrameUtils;
 122import eu.siacs.conversations.utils.UIHelper;
 123import eu.siacs.conversations.xmpp.Jid;
 124import eu.siacs.conversations.xmpp.mam.MamReference;
 125import eu.siacs.conversations.xml.Element;
 126
 127import java.net.URI;
 128import java.util.Arrays;
 129import java.util.Collection;
 130import java.util.List;
 131import java.util.Locale;
 132import java.util.regex.Matcher;
 133import java.util.regex.Pattern;
 134
 135public class MessageAdapter extends ArrayAdapter<Message> {
 136
 137    public static final String DATE_SEPARATOR_BODY = "DATE_SEPARATOR";
 138    private static final int SENT = 0;
 139    private static final int RECEIVED = 1;
 140    private static final int STATUS = 2;
 141    private static final int DATE_SEPARATOR = 3;
 142    private static final int RTP_SESSION = 4;
 143    private final XmppActivity activity;
 144    private final AudioPlayer audioPlayer;
 145    private List<String> highlightedTerm = null;
 146    private final DisplayMetrics metrics;
 147    private ConversationFragment mConversationFragment = null;
 148    private OnContactPictureClicked mOnContactPictureClickedListener;
 149    private OnContactPictureClicked mOnMessageBoxClickedListener;
 150    private OnContactPictureClicked mOnMessageBoxSwipedListener;
 151    private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
 152    private OnInlineImageLongClicked mOnInlineImageLongClickedListener;
 153    private boolean mUseGreenBackground = false;
 154    private BubbleDesign bubbleDesign = new BubbleDesign(false, false);
 155    private final boolean mForceNames;
 156    private final Map<String, WebxdcUpdate> lastWebxdcUpdate = new HashMap<>();
 157    private String selectionUuid = null;
 158
 159    public MessageAdapter(
 160            final XmppActivity activity, final List<Message> messages, final boolean forceNames) {
 161        super(activity, 0, messages);
 162        this.audioPlayer = new AudioPlayer(this);
 163        this.activity = activity;
 164        metrics = getContext().getResources().getDisplayMetrics();
 165        updatePreferences();
 166        this.mForceNames = forceNames;
 167    }
 168
 169    public MessageAdapter(final XmppActivity activity, final List<Message> messages) {
 170        this(activity, messages, false);
 171    }
 172
 173    private static void resetClickListener(View... views) {
 174        for (View view : views) {
 175            if (view != null) view.setOnClickListener(null);
 176        }
 177    }
 178
 179    public void flagScreenOn() {
 180        activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
 181    }
 182
 183    public void flagScreenOff() {
 184        activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
 185    }
 186
 187    public void setVolumeControl(final int stream) {
 188        activity.setVolumeControlStream(stream);
 189    }
 190
 191    public void setOnContactPictureClicked(OnContactPictureClicked listener) {
 192        this.mOnContactPictureClickedListener = listener;
 193    }
 194
 195    public void setOnMessageBoxClicked(OnContactPictureClicked listener) {
 196        this.mOnMessageBoxClickedListener = listener;
 197    }
 198
 199    public void setOnMessageBoxSwiped(OnContactPictureClicked listener) {
 200        this.mOnMessageBoxSwipedListener = listener;
 201    }
 202
 203    public void setConversationFragment(ConversationFragment frag) {
 204        mConversationFragment = frag;
 205    }
 206
 207    public void quoteText(String text) {
 208        if (mConversationFragment != null) mConversationFragment.quoteText(text);
 209    }
 210
 211    public boolean hasSelection() {
 212        return selectionUuid != null;
 213    }
 214
 215    public Activity getActivity() {
 216        return activity;
 217    }
 218
 219    public void setOnContactPictureLongClicked(OnContactPictureLongClicked listener) {
 220        this.mOnContactPictureLongClickedListener = listener;
 221    }
 222
 223    public void setOnInlineImageLongClicked(OnInlineImageLongClicked listener) {
 224        this.mOnInlineImageLongClickedListener = listener;
 225    }
 226
 227    @Override
 228    public int getViewTypeCount() {
 229        return 5;
 230    }
 231
 232    private int getItemViewType(Message message) {
 233        if (message.getType() == Message.TYPE_STATUS) {
 234            if (DATE_SEPARATOR_BODY.equals(message.getBody())) {
 235                return DATE_SEPARATOR;
 236            } else {
 237                return STATUS;
 238            }
 239        } else if (message.getType() == Message.TYPE_RTP_SESSION) {
 240            return RTP_SESSION;
 241        } else if (message.getStatus() <= Message.STATUS_RECEIVED) {
 242            return RECEIVED;
 243        } else {
 244            return SENT;
 245        }
 246    }
 247
 248    @Override
 249    public int getItemViewType(int position) {
 250        return this.getItemViewType(getItem(position));
 251    }
 252
 253    private void displayStatus(
 254            final ViewHolder viewHolder,
 255            final Message message,
 256            final int type,
 257            final BubbleColor bubbleColor) {
 258        final int mergedStatus = message.getMergedStatus();
 259        final boolean error;
 260        if (viewHolder.indicatorReceived != null) {
 261            viewHolder.indicatorReceived.setVisibility(View.GONE);
 262        }
 263        final Transferable transferable = message.getTransferable();
 264        final boolean multiReceived =
 265                message.getConversation().getMode() == Conversation.MODE_MULTI
 266                        && mergedStatus <= Message.STATUS_RECEIVED;
 267        final String fileSize;
 268        if (message.isFileOrImage()
 269                || transferable != null
 270                || MessageUtils.unInitiatedButKnownSize(message)) {
 271            final FileParams params = message.getFileParams();
 272            fileSize = params.size != null ? UIHelper.filesizeToString(params.size) : null;
 273            if (message.getStatus() == Message.STATUS_SEND_FAILED
 274                    || (transferable != null
 275                            && (transferable.getStatus() == Transferable.STATUS_FAILED
 276                                    || transferable.getStatus()
 277                                            == Transferable.STATUS_CANCELLED))) {
 278                error = true;
 279            } else {
 280                error = message.getStatus() == Message.STATUS_SEND_FAILED;
 281            }
 282        } else {
 283            fileSize = null;
 284            error = message.getStatus() == Message.STATUS_SEND_FAILED;
 285        }
 286        if (type == SENT) {
 287            final @DrawableRes Integer receivedIndicator =
 288                    getMessageStatusAsDrawable(message, mergedStatus);
 289            if (receivedIndicator == null) {
 290                viewHolder.indicatorReceived.setVisibility(View.INVISIBLE);
 291            } else {
 292                viewHolder.indicatorReceived.setImageResource(receivedIndicator);
 293                if (mergedStatus == Message.STATUS_SEND_FAILED) {
 294                    setImageTintError(viewHolder.indicatorReceived);
 295                } else {
 296                    setImageTint(viewHolder.indicatorReceived, bubbleColor);
 297                }
 298                viewHolder.indicatorReceived.setVisibility(View.VISIBLE);
 299            }
 300        }
 301        final var additionalStatusInfo = getAdditionalStatusInfo(message, mergedStatus);
 302
 303        if (error && type == SENT) {
 304            viewHolder.time.setTextColor(
 305                    MaterialColors.getColor(
 306                            viewHolder.time, com.google.android.material.R.attr.colorError));
 307        } else {
 308            setTextColor(viewHolder.time, bubbleColor);
 309        }
 310        setTextColor(viewHolder.subject, bubbleColor);
 311        if (message.getEncryption() == Message.ENCRYPTION_NONE) {
 312            viewHolder.indicator.setVisibility(View.GONE);
 313        } else {
 314            boolean verified = false;
 315            if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
 316                final FingerprintStatus status =
 317                        message.getConversation()
 318                                .getAccount()
 319                                .getAxolotlService()
 320                                .getFingerprintTrust(message.getFingerprint());
 321                if (status != null && status.isVerified()) {
 322                    verified = true;
 323                }
 324            }
 325            if (verified) {
 326                viewHolder.indicator.setImageResource(R.drawable.ic_verified_user_24dp);
 327            } else {
 328                viewHolder.indicator.setImageResource(R.drawable.ic_lock_24dp);
 329            }
 330            if (error && type == SENT) {
 331                setImageTintError(viewHolder.indicator);
 332            } else {
 333                setImageTint(viewHolder.indicator, bubbleColor);
 334            }
 335            viewHolder.indicator.setVisibility(View.VISIBLE);
 336        }
 337
 338        if (viewHolder.edit_indicator != null) {
 339            if (message.edited()) {
 340                viewHolder.edit_indicator.setVisibility(View.VISIBLE);
 341                if (error && type == SENT) {
 342                    setImageTintError(viewHolder.edit_indicator);
 343                } else {
 344                    setImageTint(viewHolder.edit_indicator, bubbleColor);
 345                }
 346            } else {
 347                viewHolder.edit_indicator.setVisibility(View.GONE);
 348            }
 349        }
 350
 351        final String formattedTime =
 352                UIHelper.readableTimeDifferenceFull(getContext(), message.getMergedTimeSent());
 353        final String bodyLanguage = message.getBodyLanguage();
 354        final ImmutableList.Builder<String> timeInfoBuilder = new ImmutableList.Builder<>();
 355        if (message.getStatus() <= Message.STATUS_RECEIVED) {
 356            timeInfoBuilder.add(formattedTime);
 357            if (fileSize != null) {
 358                timeInfoBuilder.add(fileSize);
 359            }
 360            if (mForceNames || multiReceived || (message.getTrueCounterpart() != null && message.getContact() != null)) {
 361                final String displayName = UIHelper.getMessageDisplayName(message);
 362                if (displayName != null) {
 363                    timeInfoBuilder.add(displayName);
 364                }
 365            }
 366            if (bodyLanguage != null) {
 367                timeInfoBuilder.add(bodyLanguage.toUpperCase(Locale.US));
 368            }
 369        } else {
 370            if (bodyLanguage != null) {
 371                timeInfoBuilder.add(bodyLanguage.toUpperCase(Locale.US));
 372            }
 373            if (fileSize != null) {
 374                timeInfoBuilder.add(fileSize);
 375            }
 376            // for space reasons we display only 'additional status info' (send progress or concrete
 377            // failure reason) or the time
 378            if (additionalStatusInfo != null) {
 379                timeInfoBuilder.add(additionalStatusInfo);
 380            } else {
 381                timeInfoBuilder.add(formattedTime);
 382            }
 383        }
 384        final var timeInfo = timeInfoBuilder.build();
 385        viewHolder.time.setText(Joiner.on(" \u00B7 ").join(timeInfo));
 386    }
 387
 388    public static @DrawableRes Integer getMessageStatusAsDrawable(
 389            final Message message, final int status) {
 390        final var transferable = message.getTransferable();
 391        return switch (status) {
 392            case Message.STATUS_WAITING -> R.drawable.ic_more_horiz_24dp;
 393            case Message.STATUS_UNSEND -> transferable == null ? null : R.drawable.ic_upload_24dp;
 394            case Message.STATUS_SEND -> R.drawable.ic_done_24dp;
 395            case Message.STATUS_SEND_RECEIVED, Message.STATUS_SEND_DISPLAYED -> R.drawable
 396                    .ic_done_all_24dp;
 397            case Message.STATUS_SEND_FAILED -> {
 398                final String errorMessage = message.getErrorMessage();
 399                if (Message.ERROR_MESSAGE_CANCELLED.equals(errorMessage)) {
 400                    yield R.drawable.ic_cancel_24dp;
 401                } else {
 402                    yield R.drawable.ic_error_24dp;
 403                }
 404            }
 405            case Message.STATUS_OFFERED -> R.drawable.ic_p2p_24dp;
 406            default -> null;
 407        };
 408    }
 409
 410    @Nullable
 411    private String getAdditionalStatusInfo(final Message message, final int mergedStatus) {
 412        final String additionalStatusInfo;
 413        if (mergedStatus == Message.STATUS_SEND_FAILED) {
 414            final String errorMessage = Strings.nullToEmpty(message.getErrorMessage());
 415            final String[] errorParts = errorMessage.split("\\u001f", 2);
 416            if (errorParts.length == 2 && errorParts[0].equals("file-too-large")) {
 417                additionalStatusInfo = getContext().getString(R.string.file_too_large);
 418            } else {
 419                additionalStatusInfo = null;
 420            }
 421        } else if (mergedStatus == Message.STATUS_UNSEND) {
 422            final var transferable = message.getTransferable();
 423            if (transferable == null) {
 424                return null;
 425            }
 426            return getContext().getString(R.string.sending_file, transferable.getProgress());
 427        } else {
 428            additionalStatusInfo = null;
 429        }
 430        return additionalStatusInfo;
 431    }
 432
 433    private void displayInfoMessage(
 434            ViewHolder viewHolder, CharSequence text, final BubbleColor bubbleColor) {
 435        viewHolder.download_button.setVisibility(View.GONE);
 436        viewHolder.audioPlayer.setVisibility(View.GONE);
 437        viewHolder.image.setVisibility(View.GONE);
 438        viewHolder.messageBody.setVisibility(View.VISIBLE);
 439        viewHolder.messageBody.setText(text);
 440        viewHolder.messageBody.setTextColor(
 441                bubbleToOnSurfaceVariant(viewHolder.messageBody, bubbleColor));
 442        viewHolder.messageBody.setTextIsSelectable(false);
 443    }
 444
 445    private void displayEmojiMessage(
 446            final ViewHolder viewHolder, final SpannableStringBuilder body, final BubbleColor bubbleColor) {
 447        viewHolder.download_button.setVisibility(View.GONE);
 448        viewHolder.audioPlayer.setVisibility(View.GONE);
 449        viewHolder.image.setVisibility(View.GONE);
 450        viewHolder.messageBody.setVisibility(View.VISIBLE);
 451        setTextColor(viewHolder.messageBody, bubbleColor);
 452        ImageSpan[] imageSpans = body.getSpans(0, body.length(), ImageSpan.class);
 453        float size = imageSpans.length == 1 || Emoticons.isEmoji(body.toString()) ? 3.0f : 2.0f;
 454        body.setSpan(
 455                new RelativeSizeSpan(size), 0, body.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 456        viewHolder.messageBody.setText(body);
 457    }
 458
 459    private void applyQuoteSpan(
 460            final TextView textView,
 461            SpannableStringBuilder body,
 462            int start,
 463            int end,
 464            final BubbleColor bubbleColor) {
 465        if (start > 1 && !"\n\n".equals(body.subSequence(start - 2, start).toString())) {
 466            body.insert(start++, "\n");
 467            body.setSpan(
 468                    new DividerSpan(false), start - 2, start, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 469            end++;
 470        }
 471        if (end < body.length() - 1 && !"\n\n".equals(body.subSequence(end, end + 2).toString())) {
 472            body.insert(end, "\n");
 473            body.setSpan(
 474                new DividerSpan(false),
 475                end,
 476                end + ("\n".equals(body.subSequence(end + 1, end + 2).toString()) ? 2 : 1),
 477                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
 478            );
 479        }
 480        final DisplayMetrics metrics = getContext().getResources().getDisplayMetrics();
 481        body.setSpan(
 482                new QuoteSpan(bubbleToOnSurfaceVariant(textView, bubbleColor), metrics),
 483                start,
 484                end,
 485                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 486    }
 487
 488    public boolean handleTextQuotes(final TextView textView, final SpannableStringBuilder body) {
 489        final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
 490        final BubbleColor bubbleColor = colorfulBackground ? BubbleColor.SECONDARY : BubbleColor.SURFACE;
 491        return handleTextQuotes(textView, body, bubbleColor);
 492    }
 493
 494    /**
 495     * Applies QuoteSpan to group of lines which starts with > or » characters. Appends likebreaks
 496     * and applies DividerSpan to them to show a padding between quote and text.
 497     */
 498    public boolean handleTextQuotes(
 499            final TextView textView,
 500            final SpannableStringBuilder body,
 501            final BubbleColor bubbleColor) {
 502        boolean startsWithQuote = false;
 503        int quoteDepth = 1;
 504        while (QuoteHelper.bodyContainsQuoteStart(body) && quoteDepth <= Config.QUOTE_MAX_DEPTH) {
 505            char previous = '\n';
 506            int lineStart = -1;
 507            int lineTextStart = -1;
 508            int quoteStart = -1;
 509            for (int i = 0; i <= body.length(); i++) {
 510                char current = body.length() > i ? body.charAt(i) : '\n';
 511                if (lineStart == -1) {
 512                    if (previous == '\n') {
 513                        if (i < body.length() && QuoteHelper.isPositionQuoteStart(body, i)) {
 514                            // Line start with quote
 515                            lineStart = i;
 516                            if (quoteStart == -1) quoteStart = i;
 517                            if (i == 0) startsWithQuote = true;
 518                        } else if (quoteStart >= 0) {
 519                            // Line start without quote, apply spans there
 520                            applyQuoteSpan(textView, body, quoteStart, i - 1, bubbleColor);
 521                            quoteStart = -1;
 522                        }
 523                    }
 524                } else {
 525                    // Remove extra spaces between > and first character in the line
 526                    // > character will be removed too
 527                    if (current != ' ' && lineTextStart == -1) {
 528                        lineTextStart = i;
 529                    }
 530                    if (current == '\n') {
 531                        body.delete(lineStart, lineTextStart);
 532                        i -= lineTextStart - lineStart;
 533                        if (i == lineStart) {
 534                            // Avoid empty lines because span over empty line can be hidden
 535                            body.insert(i++, " ");
 536                        }
 537                        lineStart = -1;
 538                        lineTextStart = -1;
 539                    }
 540                }
 541                previous = current;
 542            }
 543            if (quoteStart >= 0) {
 544                // Apply spans to finishing open quote
 545                applyQuoteSpan(textView, body, quoteStart, body.length(), bubbleColor);
 546            }
 547            quoteDepth++;
 548        }
 549        return startsWithQuote;
 550    }
 551
 552    private SpannableStringBuilder getSpannableBody(final Message message) {
 553        Drawable fallbackImg = ResourcesCompat.getDrawable(activity.getResources(), R.drawable.ic_photo_24dp, null);
 554        return message.getMergedBody((cid) -> {
 555            try {
 556                DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
 557                if (f == null || !f.canRead()) {
 558                    if (!message.trusted() && !message.getConversation().canInferPresence()) return null;
 559
 560                    try {
 561                        new BobTransfer(BobTransfer.uri(cid), message.getConversation().getAccount(), message.getCounterpart(), activity.xmppConnectionService).start();
 562                    } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
 563                    return null;
 564                }
 565
 566                Drawable d = activity.xmppConnectionService.getFileBackend().getThumbnail(f, activity.getResources(), (int) (metrics.density * 288), true);
 567                if (d == null) {
 568                    new ThumbnailTask().execute(f);
 569                }
 570                return d;
 571            } catch (final IOException e) {
 572                return null;
 573            }
 574        }, fallbackImg);
 575    }
 576
 577    private void displayTextMessage(
 578            final ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
 579        viewHolder.download_button.setVisibility(View.GONE);
 580        viewHolder.image.setVisibility(View.GONE);
 581        viewHolder.audioPlayer.setVisibility(View.GONE);
 582        viewHolder.messageBody.setVisibility(View.VISIBLE);
 583        setTextColor(viewHolder.messageBody, bubbleColor);
 584        setTextSize(viewHolder.messageBody, this.bubbleDesign.largeFont);
 585
 586        final ViewGroup.LayoutParams layoutParams = viewHolder.messageBody.getLayoutParams();
 587        layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT;
 588        viewHolder.messageBody.setLayoutParams(layoutParams);
 589
 590        viewHolder.messageBody.setTypeface(null, Typeface.NORMAL);
 591
 592        if (message.getBody() != null && !message.getBody().equals("")) {
 593            viewHolder.messageBody.setTextIsSelectable(true);
 594            viewHolder.messageBody.setVisibility(View.VISIBLE);
 595            final String nick = UIHelper.getMessageDisplayName(message);
 596            SpannableStringBuilder body = getSpannableBody(message);
 597            boolean hasMeCommand = message.hasMeCommand();
 598            if (hasMeCommand) {
 599                body = body.replace(0, Message.ME_COMMAND.length(), nick + " ");
 600            }
 601            if (body.length() > Config.MAX_DISPLAY_MESSAGE_CHARS) {
 602                body = new SpannableStringBuilder(body, 0, Config.MAX_DISPLAY_MESSAGE_CHARS);
 603                body.append("\u2026");
 604            }
 605            Message.MergeSeparator[] mergeSeparators =
 606                    body.getSpans(0, body.length(), Message.MergeSeparator.class);
 607            for (Message.MergeSeparator mergeSeparator : mergeSeparators) {
 608                int start = body.getSpanStart(mergeSeparator);
 609                int end = body.getSpanEnd(mergeSeparator);
 610                body.setSpan(new DividerSpan(true), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 611            }
 612            for (final android.text.style.QuoteSpan quote : body.getSpans(0, body.length(), android.text.style.QuoteSpan.class)) {
 613                int start = body.getSpanStart(quote);
 614                int end = body.getSpanEnd(quote);
 615                body.removeSpan(quote);
 616                applyQuoteSpan(viewHolder.messageBody, body, start, end, bubbleColor);
 617            }
 618            boolean startsWithQuote = handleTextQuotes(viewHolder.messageBody, body, bubbleColor);
 619            if (!message.isPrivateMessage()) {
 620                if (hasMeCommand) {
 621                    body.setSpan(
 622                            new StyleSpan(Typeface.BOLD_ITALIC),
 623                            0,
 624                            nick.length(),
 625                            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 626                }
 627            } else {
 628                String privateMarker;
 629                if (message.getStatus() <= Message.STATUS_RECEIVED) {
 630                    privateMarker = activity.getString(R.string.private_message);
 631                } else {
 632                    Jid cp = message.getCounterpart();
 633                    privateMarker =
 634                            activity.getString(
 635                                    R.string.private_message_to,
 636                                    Strings.nullToEmpty(cp == null ? null : cp.getResource()));
 637                }
 638                body.insert(0, privateMarker);
 639                int privateMarkerIndex = privateMarker.length();
 640                if (startsWithQuote) {
 641                    body.insert(privateMarkerIndex, "\n\n");
 642                    body.setSpan(
 643                            new DividerSpan(false),
 644                            privateMarkerIndex,
 645                            privateMarkerIndex + 2,
 646                            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 647                } else {
 648                    body.insert(privateMarkerIndex, " ");
 649                }
 650                body.setSpan(
 651                        new ForegroundColorSpan(
 652                                bubbleToOnSurfaceVariant(viewHolder.messageBody, bubbleColor)),
 653                        0,
 654                        privateMarkerIndex,
 655                        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 656                body.setSpan(
 657                        new StyleSpan(Typeface.BOLD),
 658                        0,
 659                        privateMarkerIndex,
 660                        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 661                if (hasMeCommand) {
 662                    body.setSpan(
 663                            new StyleSpan(Typeface.BOLD_ITALIC),
 664                            privateMarkerIndex + 1,
 665                            privateMarkerIndex + 1 + nick.length(),
 666                            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 667                }
 668            }
 669            if (message.getConversation().getMode() == Conversation.MODE_MULTI
 670                    && message.getStatus() == Message.STATUS_RECEIVED) {
 671                if (message.getConversation() instanceof Conversation conversation) {
 672                    Pattern pattern =
 673                            NotificationService.generateNickHighlightPattern(
 674                                    conversation.getMucOptions().getActualNick());
 675                    Matcher matcher = pattern.matcher(body);
 676                    while (matcher.find()) {
 677                        body.setSpan(
 678                                new StyleSpan(Typeface.BOLD),
 679                                matcher.start(),
 680                                matcher.end(),
 681                                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 682                    }
 683
 684                    pattern = NotificationService.generateNickHighlightPattern(conversation.getMucOptions().getActualName());
 685                    matcher = pattern.matcher(body);
 686                    while (matcher.find()) {
 687                        body.setSpan(new StyleSpan(Typeface.BOLD), matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 688                    }
 689                }
 690            }
 691            Matcher matcher = Emoticons.getEmojiPattern(body).matcher(body);
 692            while (matcher.find()) {
 693                if (matcher.start() < matcher.end()) {
 694                    body.setSpan(
 695                            new RelativeSizeSpan(1.2f),
 696                            matcher.start(),
 697                            matcher.end(),
 698                            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 699                }
 700            }
 701
 702            StylingHelper.format(body, viewHolder.messageBody.getCurrentTextColor());
 703            MyLinkify.addLinks(body, message.getConversation().getAccount(), message.getConversation().getJid());
 704            if (highlightedTerm != null) {
 705                StylingHelper.highlight(viewHolder.messageBody, body, highlightedTerm);
 706            }
 707
 708            viewHolder.messageBody.setAutoLinkMask(0);
 709            viewHolder.messageBody.setText(body);
 710            BetterLinkMovementMethod method = new BetterLinkMovementMethod() {
 711                @Override
 712                protected void dispatchUrlLongClick(TextView tv, ClickableSpan span) {
 713                    if (span instanceof URLSpan || mOnInlineImageLongClickedListener == null) {
 714                        tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
 715                        super.dispatchUrlLongClick(tv, span);
 716                        return;
 717                    }
 718
 719                    Spannable body = (Spannable) tv.getText();
 720                    ImageSpan[] imageSpans = body.getSpans(body.getSpanStart(span), body.getSpanEnd(span), ImageSpan.class);
 721                    if (imageSpans.length > 0) {
 722                        Uri uri = Uri.parse(imageSpans[0].getSource());
 723                        Cid cid = BobTransfer.cid(uri);
 724                        if (cid == null) return;
 725                        if (mOnInlineImageLongClickedListener.onInlineImageLongClicked(cid)) {
 726                            tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
 727                        }
 728                    }
 729                }
 730            };
 731            method.setOnLinkLongClickListener((tv, url) -> {
 732                tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
 733                ShareUtil.copyLinkToClipboard(activity, url);
 734                return true;
 735            });
 736            viewHolder.messageBody.setMovementMethod(method);
 737        } else {
 738            viewHolder.messageBody.setText("");
 739            viewHolder.messageBody.setTextIsSelectable(false);
 740            toggleWhisperInfo(viewHolder, message, bubbleColor);
 741        }
 742    }
 743
 744    private void displayDownloadableMessage(
 745            ViewHolder viewHolder,
 746            final Message message,
 747            String text,
 748            final BubbleColor bubbleColor, final int type) {
 749        displayTextMessage(viewHolder, message, bubbleColor, type);
 750        viewHolder.image.setVisibility(View.GONE);
 751        List<Element> thumbs = message.getFileParams() != null ? message.getFileParams().getThumbnails() : null;
 752        if (thumbs != null && !thumbs.isEmpty()) {
 753            for (Element thumb : thumbs) {
 754                Uri uri = Uri.parse(thumb.getAttribute("uri"));
 755                if (uri.getScheme().equals("data")) {
 756                    String[] parts = uri.getSchemeSpecificPart().split(",", 2);
 757                    parts = parts[0].split(";");
 758                    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;
 759                } else if (uri.getScheme().equals("cid")) {
 760                    Cid cid = BobTransfer.cid(uri);
 761                    if (cid == null) continue;
 762                    DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
 763                    if (f == null || !f.canRead()) {
 764                        if (!message.trusted() && !message.getConversation().canInferPresence()) continue;
 765
 766                        try {
 767                            new BobTransfer(BobTransfer.uri(cid), message.getConversation().getAccount(), message.getCounterpart(), activity.xmppConnectionService).start();
 768                        } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
 769                        continue;
 770                    }
 771                } else {
 772                    continue;
 773                }
 774
 775                int width = message.getFileParams().width;
 776                if (width < 1 && thumb.getAttribute("width") != null) width = Integer.parseInt(thumb.getAttribute("width"));
 777                if (width < 1) width = 1920;
 778
 779                int height = message.getFileParams().height;
 780                if (height < 1 && thumb.getAttribute("height") != null) height = Integer.parseInt(thumb.getAttribute("height"));
 781                if (height < 1) height = 1080;
 782
 783                viewHolder.image.setVisibility(View.VISIBLE);
 784                imagePreviewLayout(width, height, viewHolder.image, true, type, viewHolder);
 785                activity.loadBitmap(message, viewHolder.image);
 786                viewHolder.image.setOnClickListener(v -> ConversationFragment.downloadFile(activity, message));
 787
 788                break;
 789            }
 790        }
 791        viewHolder.audioPlayer.setVisibility(View.GONE);
 792        viewHolder.download_button.setVisibility(View.VISIBLE);
 793        viewHolder.download_button.setText(text);
 794        final var attachment = Attachment.of(message);
 795        final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
 796        viewHolder.download_button.setIconResource(imageResource);
 797        viewHolder.download_button.setOnClickListener(
 798                v -> ConversationFragment.downloadFile(activity, message));
 799    }
 800
 801    private void displayWebxdcMessage(ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
 802        Cid webxdcCid = message.getFileParams().getCids().get(0);
 803        WebxdcPage webxdc = new WebxdcPage(activity, webxdcCid, message, activity.xmppConnectionService);
 804        displayTextMessage(viewHolder, message, bubbleColor, type);
 805        viewHolder.image.setVisibility(View.GONE);
 806        viewHolder.audioPlayer.setVisibility(View.GONE);
 807        viewHolder.download_button.setVisibility(View.VISIBLE);
 808        viewHolder.download_button.setText("Open " + webxdc.getName());
 809        viewHolder.download_button.setOnClickListener(v -> {
 810            Conversation conversation = (Conversation) message.getConversation();
 811            if (!conversation.switchToSession("webxdc\0" + message.getUuid())) {
 812                conversation.startWebxdc(webxdc);
 813            }
 814        });
 815
 816        final WebxdcUpdate lastUpdate;
 817        synchronized(lastWebxdcUpdate) { lastUpdate = lastWebxdcUpdate.get(message.getUuid()); }
 818        if (lastUpdate == null) {
 819            new Thread(() -> {
 820                final WebxdcUpdate update = activity.xmppConnectionService.findLastWebxdcUpdate(message);
 821                if (update != null) {
 822                    synchronized(lastWebxdcUpdate) { lastWebxdcUpdate.put(message.getUuid(), update); }
 823                    activity.xmppConnectionService.updateConversationUi();
 824                }
 825            }).start();
 826        } else {
 827            if (lastUpdate != null && (lastUpdate.getSummary() != null || lastUpdate.getDocument() != null)) {
 828                viewHolder.messageBody.setVisibility(View.VISIBLE);
 829                viewHolder.messageBody.setText(
 830                    (lastUpdate.getDocument() == null ? "" : lastUpdate.getDocument() + "\n") +
 831                    (lastUpdate.getSummary() == null ? "" : lastUpdate.getSummary())
 832                );
 833            }
 834        }
 835
 836        final LruCache<String, Drawable> cache = activity.xmppConnectionService.getDrawableCache();
 837        final Drawable d = cache.get("webxdc:icon:" + webxdcCid);
 838        if (d == null) {
 839            new Thread(() -> {
 840                Drawable icon = webxdc.getIcon();
 841                if (icon != null) {
 842                    cache.put("webxdc:icon:" + webxdcCid, icon);
 843                    activity.xmppConnectionService.updateConversationUi();
 844                }
 845            }).start();
 846        } else {
 847            viewHolder.image.setVisibility(View.VISIBLE);
 848            viewHolder.image.setImageDrawable(d);
 849        }
 850    }
 851
 852    private void displayOpenableMessage(
 853            ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
 854        displayTextMessage(viewHolder, message, bubbleColor, type);
 855        viewHolder.image.setVisibility(View.GONE);
 856        viewHolder.audioPlayer.setVisibility(View.GONE);
 857        viewHolder.download_button.setVisibility(View.VISIBLE);
 858        viewHolder.download_button.setText(
 859                activity.getString(
 860                        R.string.open_x_file,
 861                        UIHelper.getFileDescriptionString(activity, message)));
 862        final var attachment = Attachment.of(message);
 863        final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
 864        viewHolder.download_button.setIconResource(imageResource);
 865        viewHolder.download_button.setOnClickListener(v -> openDownloadable(message));
 866    }
 867
 868    private void displayLocationMessage(
 869            ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
 870        displayTextMessage(viewHolder, message, bubbleColor, type);
 871        viewHolder.image.setVisibility(View.GONE);
 872        viewHolder.audioPlayer.setVisibility(View.GONE);
 873        viewHolder.download_button.setVisibility(View.VISIBLE);
 874        viewHolder.download_button.setText(R.string.show_location);
 875        final var attachment = Attachment.of(message);
 876        final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
 877        viewHolder.download_button.setIconResource(imageResource);
 878        viewHolder.download_button.setOnClickListener(v -> showLocation(message));
 879    }
 880
 881    private void displayAudioMessage(
 882            ViewHolder viewHolder, Message message, final BubbleColor bubbleColor, final int type) {
 883        displayTextMessage(viewHolder, message, bubbleColor, type);
 884        viewHolder.image.setVisibility(View.GONE);
 885        viewHolder.download_button.setVisibility(View.GONE);
 886        final RelativeLayout audioPlayer = viewHolder.audioPlayer;
 887        audioPlayer.setVisibility(View.VISIBLE);
 888        AudioPlayer.ViewHolder.get(audioPlayer).setBubbleColor(bubbleColor);
 889        this.audioPlayer.init(audioPlayer, message);
 890    }
 891
 892    private void displayMediaPreviewMessage(
 893            ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
 894        displayTextMessage(viewHolder, message, bubbleColor, type);
 895        viewHolder.download_button.setVisibility(View.GONE);
 896        viewHolder.audioPlayer.setVisibility(View.GONE);
 897        viewHolder.image.setVisibility(View.VISIBLE);
 898        final FileParams params = message.getFileParams();
 899        imagePreviewLayout(params.width, params.height, viewHolder.image, viewHolder.messageBody.getVisibility() != View.GONE, type, viewHolder);
 900        activity.loadBitmap(message, viewHolder.image);
 901        viewHolder.image.setOnClickListener(v -> openDownloadable(message));
 902    }
 903
 904    private void imagePreviewLayout(int w, int h, ShapeableImageView image, boolean withOther, int type, ViewHolder viewHolder) {
 905        final float target = activity.getResources().getDimension(R.dimen.image_preview_width);
 906        final int scaledW;
 907        final int scaledH;
 908        if (Math.max(h, w) * metrics.density <= target) {
 909            scaledW = (int) (w * metrics.density);
 910            scaledH = (int) (h * metrics.density);
 911        } else if (Math.max(h, w) <= target) {
 912            scaledW = w;
 913            scaledH = h;
 914        } else if (w <= h) {
 915            scaledW = (int) (w / ((double) h / target));
 916            scaledH = (int) target;
 917        } else {
 918            scaledW = (int) target;
 919            scaledH = (int) (h / ((double) w / target));
 920        }
 921        final var small = withOther ? scaledW < target : scaledW < 110 * metrics.density;
 922        final LinearLayout.LayoutParams layoutParams =
 923                new LinearLayout.LayoutParams(scaledW, scaledH);
 924        image.setLayoutParams(layoutParams);
 925
 926        final var bubbleRadius = activity.getResources().getDimension(R.dimen.bubble_radius);
 927        var shape = new ShapeAppearanceModel.Builder().setTopRightCorner(CornerFamily.ROUNDED, bubbleRadius);
 928        if (type == SENT) {
 929            shape = shape.setTopLeftCorner(CornerFamily.ROUNDED, bubbleRadius);
 930        }
 931        if (small) {
 932            final var imageRadius = activity.getResources().getDimension(R.dimen.image_radius);
 933            shape = shape.setAllCorners(CornerFamily.ROUNDED, imageRadius);
 934            image.setPadding(0, (int)(8 * metrics.density), 0, 0);
 935        } else {
 936            image.setPadding(0, 0, 0, 0);
 937        }
 938        image.setShapeAppearanceModel(shape.build());
 939
 940        if (!small) {
 941            final ViewGroup.LayoutParams blayoutParams = viewHolder.messageBody.getLayoutParams();
 942            blayoutParams.width = (int) (target - (22 * metrics.density));
 943            viewHolder.messageBody.setLayoutParams(blayoutParams);
 944        }
 945    }
 946
 947    private void toggleWhisperInfo(
 948            ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor) {
 949        if (message.isPrivateMessage()) {
 950            final String privateMarker;
 951            if (message.getStatus() <= Message.STATUS_RECEIVED) {
 952                privateMarker = activity.getString(R.string.private_message);
 953            } else {
 954                Jid cp = message.getCounterpart();
 955                privateMarker =
 956                        activity.getString(
 957                                R.string.private_message_to,
 958                                Strings.nullToEmpty(cp == null ? null : cp.getResource()));
 959            }
 960            final SpannableString body = new SpannableString(privateMarker);
 961            body.setSpan(
 962                    new ForegroundColorSpan(
 963                            bubbleToOnSurfaceVariant(viewHolder.messageBody, bubbleColor)),
 964                    0,
 965                    privateMarker.length(),
 966                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 967            body.setSpan(
 968                    new StyleSpan(Typeface.BOLD),
 969                    0,
 970                    privateMarker.length(),
 971                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 972            viewHolder.messageBody.setText(body);
 973            viewHolder.messageBody.setVisibility(View.VISIBLE);
 974        } else {
 975            viewHolder.messageBody.setVisibility(View.GONE);
 976        }
 977    }
 978
 979    private void loadMoreMessages(Conversation conversation) {
 980        conversation.setLastClearHistory(0, null);
 981        activity.xmppConnectionService.updateConversation(conversation);
 982        conversation.setHasMessagesLeftOnServer(true);
 983        conversation.setFirstMamReference(null);
 984        long timestamp = conversation.getLastMessageTransmitted().getTimestamp();
 985        if (timestamp == 0) {
 986            timestamp = System.currentTimeMillis();
 987        }
 988        conversation.messagesLoaded.set(true);
 989        MessageArchiveService.Query query =
 990                activity.xmppConnectionService
 991                        .getMessageArchiveService()
 992                        .query(conversation, new MamReference(0), timestamp, false);
 993        if (query != null) {
 994            Toast.makeText(activity, R.string.fetching_history_from_server, Toast.LENGTH_LONG)
 995                    .show();
 996        } else {
 997            Toast.makeText(
 998                            activity,
 999                            R.string.not_fetching_history_retention_period,
1000                            Toast.LENGTH_SHORT)
1001                    .show();
1002        }
1003    }
1004
1005    @Override
1006    public View getView(final int position, View view, final @NonNull ViewGroup parent) {
1007        final Message message = getItem(position);
1008        final boolean omemoEncryption = message.getEncryption() == Message.ENCRYPTION_AXOLOTL;
1009        final boolean isInValidSession =
1010                message.isValidInSession() && (!omemoEncryption || message.isTrusted());
1011        final Conversational conversation = message.getConversation();
1012        final Account account = conversation.getAccount();
1013        final List<Element> commands = message.getCommands();
1014        final int type = getItemViewType(position);
1015        ViewHolder viewHolder;
1016        if (view == null) {
1017            viewHolder = new ViewHolder();
1018            switch (type) {
1019                case DATE_SEPARATOR:
1020                    view =
1021                            activity.getLayoutInflater()
1022                                    .inflate(R.layout.item_message_date_bubble, parent, false);
1023                    viewHolder.status_message = view.findViewById(R.id.message_body);
1024                    viewHolder.message_box = view.findViewById(R.id.message_box);
1025                    break;
1026                case RTP_SESSION:
1027                    view =
1028                            activity.getLayoutInflater()
1029                                    .inflate(R.layout.item_message_rtp_session, parent, false);
1030                    viewHolder.status_message = view.findViewById(R.id.message_body);
1031                    viewHolder.message_box = view.findViewById(R.id.message_box);
1032                    viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
1033                    break;
1034                case SENT:
1035                    view = activity.getLayoutInflater().inflate(R.layout.item_message_sent, parent, false);
1036                    viewHolder.status_line = view.findViewById(R.id.status_line);
1037                    viewHolder.message_box_inner = view.findViewById(R.id.message_box_inner);
1038                    viewHolder.message_box = view.findViewById(R.id.message_box);
1039                    viewHolder.contact_picture = view.findViewById(R.id.message_photo);
1040                    viewHolder.download_button = view.findViewById(R.id.download_button);
1041                    viewHolder.indicator = view.findViewById(R.id.security_indicator);
1042                    viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
1043                    viewHolder.image = view.findViewById(R.id.message_image);
1044                    viewHolder.messageBody = view.findViewById(R.id.message_body);
1045                    viewHolder.time = view.findViewById(R.id.message_time);
1046                    viewHolder.subject = view.findViewById(R.id.message_subject);
1047                    viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
1048                    viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
1049                    viewHolder.thread_identicon = view.findViewById(R.id.thread_identicon);
1050                    break;
1051                case RECEIVED:
1052                    view = activity.getLayoutInflater().inflate(R.layout.item_message_received, parent, false);
1053                    viewHolder.status_line = view.findViewById(R.id.status_line);
1054                    viewHolder.message_box_inner = view.findViewById(R.id.message_box_inner);
1055                    viewHolder.message_box = view.findViewById(R.id.message_box);
1056                    viewHolder.contact_picture = view.findViewById(R.id.message_photo);
1057                    viewHolder.download_button = view.findViewById(R.id.download_button);
1058                    viewHolder.indicator = view.findViewById(R.id.security_indicator);
1059                    viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
1060                    viewHolder.image = view.findViewById(R.id.message_image);
1061                    viewHolder.messageBody = view.findViewById(R.id.message_body);
1062                    viewHolder.time = view.findViewById(R.id.message_time);
1063                    viewHolder.subject = view.findViewById(R.id.message_subject);
1064                    viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
1065                    viewHolder.encryption = view.findViewById(R.id.message_encryption);
1066                    viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
1067                    viewHolder.commands_list = view.findViewById(R.id.commands_list);
1068                    viewHolder.thread_identicon = view.findViewById(R.id.thread_identicon);
1069                    break;
1070                case STATUS:
1071                    view =
1072                            activity.getLayoutInflater()
1073                                    .inflate(R.layout.item_message_status, parent, false);
1074                    viewHolder.contact_picture = view.findViewById(R.id.message_photo);
1075                    viewHolder.status_message = view.findViewById(R.id.status_message);
1076                    viewHolder.load_more_messages = view.findViewById(R.id.load_more_messages);
1077                    break;
1078                default:
1079                    throw new AssertionError("Unknown view type");
1080            }
1081            view.setTag(viewHolder);
1082        } else {
1083            viewHolder = (ViewHolder) view.getTag();
1084            if (viewHolder == null) {
1085                return view;
1086            }
1087        }
1088
1089        if (viewHolder.messageBody != null) {
1090            viewHolder.messageBody.setCustomSelectionActionModeCallback(new MessageTextActionModeCallback(this, viewHolder.messageBody));
1091        }
1092
1093        if (viewHolder.thread_identicon != null) {
1094            viewHolder.thread_identicon.setVisibility(View.GONE);
1095            final Element thread = message.getThread();
1096            if (thread != null) {
1097                final String threadId = thread.getContent();
1098                if (threadId != null) {
1099                    viewHolder.thread_identicon.setVisibility(View.VISIBLE);
1100                    viewHolder.thread_identicon.setColor(UIHelper.getColorForName(threadId));
1101                    viewHolder.thread_identicon.setHash(UIHelper.identiconHash(threadId));
1102                }
1103            }
1104        }
1105
1106        final var black = MaterialColors.getColor(view, com.google.android.material.R.attr.colorSecondaryContainer) == view.getContext().getColor(android.R.color.black);
1107        final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
1108        final BubbleColor bubbleColor;
1109        if (type == RECEIVED) {
1110            if (isInValidSession) {
1111                bubbleColor = colorfulBackground  || black ? BubbleColor.SECONDARY : BubbleColor.SURFACE;
1112            } else {
1113                bubbleColor = BubbleColor.WARNING;
1114            }
1115        } else {
1116            if (!colorfulBackground && black) {
1117                bubbleColor = BubbleColor.SECONDARY;
1118            } else {
1119                bubbleColor = colorfulBackground ? BubbleColor.TERTIARY : BubbleColor.SURFACE_HIGH;
1120            }
1121        }
1122
1123        if (type == DATE_SEPARATOR) {
1124            if (UIHelper.today(message.getTimeSent())) {
1125                viewHolder.status_message.setText(R.string.today);
1126            } else if (UIHelper.yesterday(message.getTimeSent())) {
1127                viewHolder.status_message.setText(R.string.yesterday);
1128            } else {
1129                viewHolder.status_message.setText(
1130                        DateUtils.formatDateTime(
1131                                activity,
1132                                message.getTimeSent(),
1133                                DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR));
1134            }
1135            if (colorfulBackground) {
1136                setBackgroundTint(viewHolder.message_box, BubbleColor.PRIMARY);
1137                setTextColor(viewHolder.status_message, BubbleColor.PRIMARY);
1138            } else {
1139                setBackgroundTint(viewHolder.message_box, BubbleColor.SURFACE_HIGH);
1140                setTextColor(viewHolder.status_message, BubbleColor.SURFACE_HIGH);
1141            }
1142            return view;
1143        } else if (type == RTP_SESSION) {
1144            final boolean received = message.getStatus() <= Message.STATUS_RECEIVED;
1145            final RtpSessionStatus rtpSessionStatus = RtpSessionStatus.of(message.getBody());
1146            final long duration = rtpSessionStatus.duration;
1147            final String callTime = UIHelper.readableTimeDifferenceFull(activity, message.getTimeSent());
1148            if (received) {
1149                if (duration > 0) {
1150                    viewHolder.status_message.setText(
1151                            activity.getString(
1152                                    R.string.incoming_call_duration_timestamp,
1153                                    TimeFrameUtils.resolve(activity, duration),
1154                                    UIHelper.readableTimeDifferenceFull(
1155                                            activity, message.getTimeSent())));
1156                } else if (rtpSessionStatus.successful) {
1157                    viewHolder.status_message.setText(activity.getString(R.string.incoming_call_timestamp, callTime));
1158                } else {
1159                    viewHolder.status_message.setText(
1160                            activity.getString(
1161                                    R.string.missed_call_timestamp,
1162                                    UIHelper.readableTimeDifferenceFull(
1163                                            activity, message.getTimeSent())));
1164                }
1165            } else {
1166                if (duration > 0) {
1167                    viewHolder.status_message.setText(
1168                            activity.getString(
1169                                    R.string.outgoing_call_duration_timestamp,
1170                                    TimeFrameUtils.resolve(activity, duration),
1171                                    UIHelper.readableTimeDifferenceFull(
1172                                            activity, message.getTimeSent())));
1173                } else {
1174                    viewHolder.status_message.setText(
1175                            activity.getString(
1176                                    R.string.outgoing_call_timestamp,
1177                                    UIHelper.readableTimeDifferenceFull(
1178                                            activity, message.getTimeSent())));
1179                }
1180            }
1181            if (colorfulBackground) {
1182                setBackgroundTint(viewHolder.message_box, BubbleColor.SECONDARY);
1183                setTextColor(viewHolder.status_message, BubbleColor.SECONDARY);
1184                setImageTint(viewHolder.indicatorReceived, BubbleColor.SECONDARY);
1185            } else {
1186                setBackgroundTint(viewHolder.message_box, BubbleColor.SURFACE_HIGH);
1187                setTextColor(viewHolder.status_message, BubbleColor.SURFACE_HIGH);
1188                setImageTint(viewHolder.indicatorReceived, BubbleColor.SURFACE_HIGH);
1189            }
1190            viewHolder.indicatorReceived.setImageResource(
1191                    RtpSessionStatus.getDrawable(received, rtpSessionStatus.successful));
1192            return view;
1193        } else if (type == STATUS) {
1194            if ("LOAD_MORE".equals(message.getBody())) {
1195                viewHolder.status_message.setVisibility(View.GONE);
1196                viewHolder.contact_picture.setVisibility(View.GONE);
1197                viewHolder.load_more_messages.setVisibility(View.VISIBLE);
1198                viewHolder.load_more_messages.setOnClickListener(
1199                        v -> loadMoreMessages((Conversation) message.getConversation()));
1200            } else {
1201                viewHolder.status_message.setVisibility(View.VISIBLE);
1202                viewHolder.load_more_messages.setVisibility(View.GONE);
1203                viewHolder.status_message.setText(message.getBody());
1204                boolean showAvatar;
1205                if (conversation.getMode() == Conversation.MODE_SINGLE) {
1206                    showAvatar = true;
1207                    AvatarWorkerTask.loadAvatar(
1208                            message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
1209                } else if (message.getCounterpart() != null
1210                        || message.getTrueCounterpart() != null
1211                        || (message.getCounterparts() != null
1212                                && message.getCounterparts().size() > 0)) {
1213                    showAvatar = true;
1214                    AvatarWorkerTask.loadAvatar(
1215                            message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
1216                } else {
1217                    showAvatar = false;
1218                }
1219                if (showAvatar) {
1220                    viewHolder.contact_picture.setAlpha(0.5f);
1221                    viewHolder.contact_picture.setVisibility(View.VISIBLE);
1222                } else {
1223                    viewHolder.contact_picture.setVisibility(View.GONE);
1224                }
1225            }
1226            return view;
1227        } else {
1228            // viewHolder.message_box.setClipToOutline(true); This eats the bubble tails on A14 for some reason
1229            AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar);
1230        }
1231
1232        resetClickListener(viewHolder.message_box, viewHolder.messageBody);
1233
1234        viewHolder.message_box.setOnClickListener(v -> {
1235            if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1236                MessageAdapter.this.mOnMessageBoxClickedListener
1237                        .onContactPictureClicked(message);
1238            }
1239        });
1240        SwipeDetector swipeDetector = new SwipeDetector((action) -> {
1241            if (action == SwipeDetector.Action.LR && MessageAdapter.this.mOnMessageBoxSwipedListener != null) {
1242                MessageAdapter.this.mOnMessageBoxSwipedListener.onContactPictureClicked(message);
1243            }
1244        });
1245        viewHolder.message_box.setOnTouchListener(swipeDetector);
1246        viewHolder.image.setOnTouchListener(swipeDetector);
1247        viewHolder.time.setOnTouchListener(swipeDetector);
1248
1249        // Treat touch-up as click so we don't have to touch twice
1250        // (touch twice is because it's waiting to see if you double-touch for text selection)
1251        viewHolder.messageBody.setOnTouchListener((v, event) -> {
1252            if (event.getAction() == MotionEvent.ACTION_UP) {
1253                if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1254                    MessageAdapter.this.mOnMessageBoxClickedListener
1255                        .onContactPictureClicked(message);
1256                }
1257            }
1258
1259            swipeDetector.onTouch(v, event);
1260
1261            return false;
1262        });
1263        viewHolder.messageBody.setOnClickListener(v -> {
1264            if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1265                MessageAdapter.this.mOnMessageBoxClickedListener
1266                        .onContactPictureClicked(message);
1267            }
1268        });
1269        viewHolder.contact_picture.setOnClickListener(v -> {
1270            if (MessageAdapter.this.mOnContactPictureClickedListener != null) {
1271                MessageAdapter.this.mOnContactPictureClickedListener
1272                        .onContactPictureClicked(message);
1273            }
1274
1275        });
1276        viewHolder.contact_picture.setOnLongClickListener(v -> {
1277            if (MessageAdapter.this.mOnContactPictureLongClickedListener != null) {
1278                MessageAdapter.this.mOnContactPictureLongClickedListener
1279                        .onContactPictureLongClicked(v, message);
1280                return true;
1281            } else {
1282                return false;
1283            }
1284        });
1285        viewHolder.messageBody.setAccessibilityDelegate(null);
1286
1287        boolean footerWrap = false;
1288
1289        final Transferable transferable = message.getTransferable();
1290        final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
1291
1292        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));
1293        if (muted) {
1294            // Muted MUC participant
1295            displayInfoMessage(viewHolder, "Muted", bubbleColor);
1296        } else if (unInitiatedButKnownSize || message.isDeleted() || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING)) {
1297            if (unInitiatedButKnownSize || (message.isDeleted() && message.getModerated() == null) || transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER) {
1298                displayDownloadableMessage(viewHolder, message, activity.getString(R.string.download_x_file, UIHelper.getFileDescriptionString(activity, message)), bubbleColor, type);
1299            } else if (transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER_CHECK_FILESIZE) {
1300                displayDownloadableMessage(viewHolder, message, activity.getString(R.string.check_x_filesize, UIHelper.getFileDescriptionString(activity, message)), bubbleColor, type);
1301            } else {
1302                displayInfoMessage(viewHolder, UIHelper.getMessagePreview(activity.xmppConnectionService, message).first, bubbleColor);
1303            }
1304        } else if (message.isFileOrImage()
1305                && message.getEncryption() != Message.ENCRYPTION_PGP
1306                && message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED) {
1307            if (message.getFileParams().width > 0 && message.getFileParams().height > 0) {
1308                displayMediaPreviewMessage(viewHolder, message, bubbleColor, type);
1309                if (!black && viewHolder.image.getLayoutParams().width > metrics.density * 110) {
1310                    footerWrap = true;
1311                }
1312            } else if (message.getFileParams().runtime > 0) {
1313                displayAudioMessage(viewHolder, message, bubbleColor, type);
1314            } else if ("application/xdc+zip".equals(message.getFileParams().getMediaType()) && message.getConversation() instanceof Conversation && message.getThread() != null && !message.getFileParams().getCids().isEmpty()) {
1315                displayWebxdcMessage(viewHolder, message, bubbleColor, type);
1316            } else {
1317                displayOpenableMessage(viewHolder, message, bubbleColor, type);
1318            }
1319        } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
1320            if (account.isPgpDecryptionServiceConnected()) {
1321                if (conversation instanceof Conversation
1322                        && !account.hasPendingPgpIntent((Conversation) conversation)) {
1323                    displayInfoMessage(
1324                            viewHolder,
1325                            activity.getString(R.string.message_decrypting),
1326                            bubbleColor);
1327                } else {
1328                    displayInfoMessage(
1329                            viewHolder, activity.getString(R.string.pgp_message), bubbleColor);
1330                }
1331            } else {
1332                displayInfoMessage(
1333                        viewHolder, activity.getString(R.string.install_openkeychain), bubbleColor);
1334                viewHolder.message_box.setOnClickListener(this::promptOpenKeychainInstall);
1335                viewHolder.messageBody.setOnClickListener(this::promptOpenKeychainInstall);
1336            }
1337        } else if (message.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) {
1338            displayInfoMessage(
1339                    viewHolder, activity.getString(R.string.decryption_failed), bubbleColor);
1340        } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE) {
1341            displayInfoMessage(
1342                    viewHolder,
1343                    activity.getString(R.string.not_encrypted_for_this_device),
1344                    bubbleColor);
1345        } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_FAILED) {
1346            displayInfoMessage(
1347                    viewHolder, activity.getString(R.string.omemo_decryption_failed), bubbleColor);
1348        } else {
1349            if (message.isGeoUri()) {
1350                displayLocationMessage(viewHolder, message, bubbleColor, type);
1351            } else if (message.treatAsDownloadable()) {
1352                try {
1353                    final URI uri = message.getOob();
1354                    displayDownloadableMessage(viewHolder,
1355                            message,
1356                            activity.getString(
1357                                    R.string.check_x_filesize_on_host,
1358                                    UIHelper.getFileDescriptionString(activity, message),
1359                                    uri.getHost()),
1360                            bubbleColor, type);
1361                } catch (Exception e) {
1362                    displayDownloadableMessage(
1363                            viewHolder,
1364                            message,
1365                            activity.getString(
1366                                    R.string.check_x_filesize,
1367                                    UIHelper.getFileDescriptionString(activity, message)),
1368                            bubbleColor, type);
1369                }
1370            } else if (message.bodyIsOnlyEmojis() && message.getType() != Message.TYPE_PRIVATE) {
1371                displayEmojiMessage(viewHolder, getSpannableBody(message), bubbleColor);
1372            } else {
1373                displayTextMessage(viewHolder, message, bubbleColor, message.getType());
1374            }
1375        }
1376
1377        viewHolder.message_box_inner.setMinimumWidth(footerWrap ? (int) (110 * metrics.density) : 0);
1378        LinearLayout.LayoutParams statusParams = (LinearLayout.LayoutParams) viewHolder.status_line.getLayoutParams();
1379        statusParams.width = footerWrap ? ViewGroup.LayoutParams.MATCH_PARENT : ViewGroup.LayoutParams.WRAP_CONTENT;
1380        viewHolder.status_line.setLayoutParams(statusParams);
1381
1382        setBackgroundTint(viewHolder.message_box, bubbleColor);
1383        setTextColor(viewHolder.messageBody, bubbleColor);
1384        viewHolder.messageBody.setLinkTextColor(bubbleToOnSurfaceColor(viewHolder.messageBody, bubbleColor));
1385
1386        if (type == RECEIVED) {
1387            if (!muted && commands != null && conversation instanceof Conversation) {
1388                CommandButtonAdapter adapter = new CommandButtonAdapter(activity);
1389                adapter.addAll(commands);
1390                viewHolder.commands_list.setAdapter(adapter);
1391                viewHolder.commands_list.setVisibility(View.VISIBLE);
1392                viewHolder.commands_list.setOnItemClickListener((p, v, pos, id) -> {
1393                    final Element command = adapter.getItem(pos);
1394                    activity.startCommand(conversation.getAccount(), command.getAttributeAsJid("jid"), command.getAttribute("node"));
1395                });
1396            } else {
1397                // It's unclear if we can set this to null...
1398                ListAdapter adapter = viewHolder.commands_list.getAdapter();
1399                if (adapter instanceof ArrayAdapter) {
1400                    ((ArrayAdapter<?>) adapter).clear();
1401                }
1402                viewHolder.commands_list.setVisibility(View.GONE);
1403                viewHolder.commands_list.setOnItemClickListener(null);
1404            }
1405
1406            setTextColor(viewHolder.encryption, bubbleColor);
1407
1408            if (isInValidSession) {
1409                viewHolder.encryption.setVisibility(View.GONE);
1410            } else {
1411                viewHolder.encryption.setVisibility(View.VISIBLE);
1412                if (omemoEncryption && !message.isTrusted()) {
1413                    viewHolder.encryption.setText(R.string.not_trusted);
1414                } else {
1415                    viewHolder.encryption.setText(
1416                            CryptoHelper.encryptionTypeToText(message.getEncryption()));
1417                }
1418            }
1419        }
1420
1421        if (type == RECEIVED || type == SENT) {
1422            String subject = message.getSubject();
1423            if (subject == null && message.getThread() != null) {
1424                final var thread = ((Conversation) message.getConversation()).getThread(message.getThread().getContent());
1425                if (thread != null) subject = thread.getSubject();
1426            }
1427            if (muted || subject == null) {
1428                viewHolder.subject.setVisibility(View.GONE);
1429            } else {
1430                viewHolder.subject.setVisibility(View.VISIBLE);
1431                viewHolder.subject.setText(subject);
1432            }
1433        }
1434
1435        displayStatus(viewHolder, message, type, bubbleColor);
1436
1437        viewHolder.messageBody.setAccessibilityDelegate(new View.AccessibilityDelegate() {
1438            @Override
1439            public void sendAccessibilityEvent(View host, int eventType) {
1440                super.sendAccessibilityEvent(host, eventType);
1441                if (eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) {
1442                    if (viewHolder.messageBody.hasSelection()) {
1443                        selectionUuid = message.getUuid();
1444                    } else if (message.getUuid() != null && message.getUuid().equals(selectionUuid)) {
1445                        selectionUuid = null;
1446                    }
1447                }
1448            }
1449        });
1450
1451        return view;
1452    }
1453
1454    private void promptOpenKeychainInstall(View view) {
1455        activity.showInstallPgpDialog();
1456    }
1457
1458    public FileBackend getFileBackend() {
1459        return activity.xmppConnectionService.getFileBackend();
1460    }
1461
1462    public void stopAudioPlayer() {
1463        audioPlayer.stop();
1464    }
1465
1466    public void unregisterListenerInAudioPlayer() {
1467        audioPlayer.unregisterListener();
1468    }
1469
1470    public void startStopPending() {
1471        audioPlayer.startStopPending();
1472    }
1473
1474    public void openDownloadable(Message message) {
1475        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
1476                && ContextCompat.checkSelfPermission(
1477                                activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)
1478                        != PackageManager.PERMISSION_GRANTED) {
1479            ConversationFragment.registerPendingMessage(activity, message);
1480            ActivityCompat.requestPermissions(
1481                    activity,
1482                    new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE},
1483                    ConversationsActivity.REQUEST_OPEN_MESSAGE);
1484            return;
1485        }
1486        final DownloadableFile file =
1487                activity.xmppConnectionService.getFileBackend().getFile(message);
1488        ViewUtil.view(activity, file);
1489    }
1490
1491    private void showLocation(Message message) {
1492        for (Intent intent : GeoHelper.createGeoIntentsFromMessage(activity, message)) {
1493            if (intent.resolveActivity(getContext().getPackageManager()) != null) {
1494                getContext().startActivity(intent);
1495                return;
1496            }
1497        }
1498        Toast.makeText(
1499                        activity,
1500                        R.string.no_application_found_to_display_location,
1501                        Toast.LENGTH_SHORT)
1502                .show();
1503    }
1504
1505    public void updatePreferences() {
1506        final AppSettings appSettings = new AppSettings(activity);
1507        this.bubbleDesign =
1508                new BubbleDesign(appSettings.isColorfulChatBubbles(), appSettings.isLargeFont());
1509    }
1510
1511    public void setHighlightedTerm(List<String> terms) {
1512        this.highlightedTerm = terms == null ? null : StylingHelper.filterHighlightedWords(terms);
1513    }
1514
1515    public interface OnContactPictureClicked {
1516        void onContactPictureClicked(Message message);
1517    }
1518
1519    public interface OnContactPictureLongClicked {
1520        void onContactPictureLongClicked(View v, Message message);
1521    }
1522
1523    public interface OnInlineImageLongClicked {
1524        boolean onInlineImageLongClicked(Cid cid);
1525    }
1526
1527    private static void setBackgroundTint(final View view, final BubbleColor bubbleColor) {
1528        view.setBackgroundTintList(bubbleToColorStateList(view, bubbleColor));
1529    }
1530
1531    private static ColorStateList bubbleToColorStateList(
1532            final View view, final BubbleColor bubbleColor) {
1533        final @AttrRes int colorAttributeResId =
1534                switch (bubbleColor) {
1535                    case SURFACE -> Activities.isNightMode(view.getContext())
1536                            ? com.google.android.material.R.attr.colorSurfaceContainerHigh
1537                            : com.google.android.material.R.attr.colorSurfaceContainerLow;
1538                    case SURFACE_HIGH -> Activities.isNightMode(view.getContext())
1539                            ? com.google.android.material.R.attr.colorSurfaceContainerHighest
1540                            : com.google.android.material.R.attr.colorSurfaceContainerHigh;
1541                    case PRIMARY -> com.google.android.material.R.attr.colorPrimaryContainer;
1542                    case SECONDARY -> com.google.android.material.R.attr.colorSecondaryContainer;
1543                    case TERTIARY -> com.google.android.material.R.attr.colorTertiaryContainer;
1544                    case WARNING -> com.google.android.material.R.attr.colorErrorContainer;
1545                };
1546        return ColorStateList.valueOf(MaterialColors.getColor(view, colorAttributeResId));
1547    }
1548
1549    public static void setImageTint(final ImageView imageView, final BubbleColor bubbleColor) {
1550        ImageViewCompat.setImageTintList(
1551                imageView, bubbleToOnSurfaceColorStateList(imageView, bubbleColor));
1552    }
1553
1554    public static void setImageTintError(final ImageView imageView) {
1555        ImageViewCompat.setImageTintList(
1556                imageView,
1557                ColorStateList.valueOf(
1558                        MaterialColors.getColor(
1559                                imageView, com.google.android.material.R.attr.colorError)));
1560    }
1561
1562    public static void setTextColor(final TextView textView, final BubbleColor bubbleColor) {
1563        final var color = bubbleToOnSurfaceColor(textView, bubbleColor);
1564        textView.setTextColor(color);
1565        if (BubbleColor.SURFACES.contains(bubbleColor)) {
1566            textView.setLinkTextColor(
1567                    MaterialColors.getColor(
1568                            textView, com.google.android.material.R.attr.colorPrimary));
1569        } else {
1570            textView.setLinkTextColor(color);
1571        }
1572    }
1573
1574    private static void setTextSize(final TextView textView, final boolean largeFont) {
1575        if (largeFont) {
1576            textView.setTextAppearance(
1577                    com.google.android.material.R.style.TextAppearance_Material3_BodyLarge);
1578        } else {
1579            textView.setTextAppearance(
1580                    com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
1581        }
1582    }
1583
1584    private static @ColorInt int bubbleToOnSurfaceVariant(
1585            final View view, final BubbleColor bubbleColor) {
1586        final @AttrRes int colorAttributeResId;
1587        if (BubbleColor.SURFACES.contains(bubbleColor)) {
1588            colorAttributeResId = com.google.android.material.R.attr.colorOnSurfaceVariant;
1589        } else {
1590            colorAttributeResId = bubbleToOnSurface(bubbleColor);
1591        }
1592        return MaterialColors.getColor(view, colorAttributeResId);
1593    }
1594
1595    private static @ColorInt int bubbleToOnSurfaceColor(
1596            final View view, final BubbleColor bubbleColor) {
1597        return MaterialColors.getColor(view, bubbleToOnSurface(bubbleColor));
1598    }
1599
1600    public static ColorStateList bubbleToOnSurfaceColorStateList(
1601            final View view, final BubbleColor bubbleColor) {
1602        return ColorStateList.valueOf(bubbleToOnSurfaceColor(view, bubbleColor));
1603    }
1604
1605    private static @AttrRes int bubbleToOnSurface(final BubbleColor bubbleColor) {
1606        return switch (bubbleColor) {
1607            case SURFACE, SURFACE_HIGH -> com.google.android.material.R.attr.colorOnSurface;
1608            case PRIMARY -> com.google.android.material.R.attr.colorOnPrimaryContainer;
1609            case SECONDARY -> com.google.android.material.R.attr.colorOnSecondaryContainer;
1610            case TERTIARY -> com.google.android.material.R.attr.colorOnTertiaryContainer;
1611            case WARNING -> com.google.android.material.R.attr.colorOnErrorContainer;
1612        };
1613    }
1614
1615    public enum BubbleColor {
1616        SURFACE,
1617        SURFACE_HIGH,
1618        PRIMARY,
1619        SECONDARY,
1620        TERTIARY,
1621        WARNING;
1622
1623        private static final Collection<BubbleColor> SURFACES =
1624                Arrays.asList(BubbleColor.SURFACE, BubbleColor.SURFACE_HIGH);
1625    }
1626
1627    private static class BubbleDesign {
1628        public final boolean colorfulChatBubbles;
1629        public final boolean largeFont;
1630
1631        private BubbleDesign(final boolean colorfulChatBubbles, final boolean largeFont) {
1632            this.colorfulChatBubbles = colorfulChatBubbles;
1633            this.largeFont = largeFont;
1634        }
1635    }
1636
1637    private static class ViewHolder {
1638
1639        public MaterialButton load_more_messages;
1640        public ImageView edit_indicator;
1641        public RelativeLayout audioPlayer;
1642        protected View status_line;
1643        protected LinearLayout message_box;
1644        protected View message_box_inner;
1645        protected MaterialButton download_button;
1646        protected ShapeableImageView image;
1647        protected ImageView indicator;
1648        protected ImageView indicatorReceived;
1649        protected TextView time;
1650        protected TextView subject;
1651        protected TextView messageBody;
1652        protected ImageView contact_picture;
1653        protected TextView status_message;
1654        protected TextView encryption;
1655        protected ListView commands_list;
1656        protected GithubIdenticonView thread_identicon;
1657    }
1658
1659    class ThumbnailTask extends AsyncTask<DownloadableFile, Void, Drawable[]> {
1660        @Override
1661        protected Drawable[] doInBackground(DownloadableFile... params) {
1662            if (isCancelled()) return null;
1663
1664            Drawable[] d = new Drawable[params.length];
1665            for (int i = 0; i < params.length; i++) {
1666                try {
1667                    d[i] = activity.xmppConnectionService.getFileBackend().getThumbnail(params[i], activity.getResources(), (int) (metrics.density * 288), false);
1668                } catch (final IOException e) {
1669                    d[i] = null;
1670                }
1671            }
1672
1673            return d;
1674        }
1675
1676        @Override
1677        protected void onPostExecute(final Drawable[] d) {
1678            if (isCancelled()) return;
1679            activity.xmppConnectionService.updateConversationUi();
1680        }
1681    }
1682}