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