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