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 eu.siacs.conversations.AppSettings;
  47import eu.siacs.conversations.Config;
  48import eu.siacs.conversations.R;
  49import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
  50import eu.siacs.conversations.databinding.ItemMessageDateBubbleBinding;
  51import eu.siacs.conversations.databinding.ItemMessageEndBinding;
  52import eu.siacs.conversations.databinding.ItemMessageRtpSessionBinding;
  53import eu.siacs.conversations.databinding.ItemMessageStartBinding;
  54import eu.siacs.conversations.databinding.ItemMessageStatusBinding;
  55import eu.siacs.conversations.entities.Account;
  56import eu.siacs.conversations.entities.Conversation;
  57import eu.siacs.conversations.entities.Conversational;
  58import eu.siacs.conversations.entities.DownloadableFile;
  59import eu.siacs.conversations.entities.Message;
  60import eu.siacs.conversations.entities.Message.FileParams;
  61import eu.siacs.conversations.entities.RtpSessionStatus;
  62import eu.siacs.conversations.entities.Transferable;
  63import eu.siacs.conversations.persistance.FileBackend;
  64import eu.siacs.conversations.services.MessageArchiveService;
  65import eu.siacs.conversations.services.NotificationService;
  66import eu.siacs.conversations.ui.Activities;
  67import eu.siacs.conversations.ui.BindingAdapters;
  68import eu.siacs.conversations.ui.ConversationFragment;
  69import eu.siacs.conversations.ui.ConversationsActivity;
  70import eu.siacs.conversations.ui.XmppActivity;
  71import eu.siacs.conversations.ui.service.AudioPlayer;
  72import eu.siacs.conversations.ui.text.DividerSpan;
  73import eu.siacs.conversations.ui.text.QuoteSpan;
  74import eu.siacs.conversations.ui.util.Attachment;
  75import eu.siacs.conversations.ui.util.AvatarWorkerTask;
  76import eu.siacs.conversations.ui.util.MyLinkify;
  77import eu.siacs.conversations.ui.util.QuoteHelper;
  78import eu.siacs.conversations.ui.util.ViewUtil;
  79import eu.siacs.conversations.ui.widget.ClickableMovementMethod;
  80import eu.siacs.conversations.utils.CryptoHelper;
  81import eu.siacs.conversations.utils.Emoticons;
  82import eu.siacs.conversations.utils.GeoHelper;
  83import eu.siacs.conversations.utils.MessageUtils;
  84import eu.siacs.conversations.utils.StylingHelper;
  85import eu.siacs.conversations.utils.TimeFrameUtils;
  86import eu.siacs.conversations.utils.UIHelper;
  87import eu.siacs.conversations.xmpp.Jid;
  88import eu.siacs.conversations.xmpp.mam.MamReference;
  89import java.net.URI;
  90import java.util.Arrays;
  91import java.util.Collection;
  92import java.util.List;
  93import java.util.Locale;
  94import java.util.regex.Matcher;
  95import java.util.regex.Pattern;
  96
  97public class MessageAdapter extends ArrayAdapter<Message> {
  98
  99    public static final String DATE_SEPARATOR_BODY = "DATE_SEPARATOR";
 100    private static final int END = 0;
 101    private static final int START = 1;
 102    private static final int STATUS = 2;
 103    private static final int DATE_SEPARATOR = 3;
 104    private static final int RTP_SESSION = 4;
 105    private final XmppActivity activity;
 106    private final AudioPlayer audioPlayer;
 107    private List<String> highlightedTerm = null;
 108    private final DisplayMetrics metrics;
 109    private OnContactPictureClicked mOnContactPictureClickedListener;
 110    private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
 111    private BubbleDesign bubbleDesign = new BubbleDesign(false, false, false, true);
 112    private final boolean mForceNames;
 113
 114    public MessageAdapter(
 115            final XmppActivity activity, final List<Message> messages, final boolean forceNames) {
 116        super(activity, 0, messages);
 117        this.audioPlayer = new AudioPlayer(this);
 118        this.activity = activity;
 119        metrics = getContext().getResources().getDisplayMetrics();
 120        updatePreferences();
 121        this.mForceNames = forceNames;
 122    }
 123
 124    public MessageAdapter(final XmppActivity activity, final List<Message> messages) {
 125        this(activity, messages, false);
 126    }
 127
 128    private static void resetClickListener(View... views) {
 129        for (View view : views) {
 130            view.setOnClickListener(null);
 131        }
 132    }
 133
 134    public void flagScreenOn() {
 135        activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
 136    }
 137
 138    public void flagScreenOff() {
 139        activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
 140    }
 141
 142    public void setVolumeControl(final int stream) {
 143        activity.setVolumeControlStream(stream);
 144    }
 145
 146    public void setOnContactPictureClicked(OnContactPictureClicked listener) {
 147        this.mOnContactPictureClickedListener = listener;
 148    }
 149
 150    public Activity getActivity() {
 151        return activity;
 152    }
 153
 154    public void setOnContactPictureLongClicked(OnContactPictureLongClicked listener) {
 155        this.mOnContactPictureLongClickedListener = listener;
 156    }
 157
 158    @Override
 159    public int getViewTypeCount() {
 160        return 5;
 161    }
 162
 163    private static int getItemViewType(final Message message, final boolean alignStart) {
 164        if (message.getType() == Message.TYPE_STATUS) {
 165            if (DATE_SEPARATOR_BODY.equals(message.getBody())) {
 166                return DATE_SEPARATOR;
 167            } else {
 168                return STATUS;
 169            }
 170        } else if (message.getType() == Message.TYPE_RTP_SESSION) {
 171            return RTP_SESSION;
 172        } else if (message.getStatus() <= Message.STATUS_RECEIVED || alignStart) {
 173            return START;
 174        } else {
 175            return END;
 176        }
 177    }
 178
 179    @Override
 180    public int getItemViewType(final int position) {
 181        return getItemViewType(getItem(position), bubbleDesign.alignStart);
 182    }
 183
 184    private void displayStatus(
 185            final BubbleMessageItemViewHolder viewHolder,
 186            final Message message,
 187            final BubbleColor bubbleColor) {
 188        final int status = message.getStatus();
 189        final boolean error;
 190        final Transferable transferable = message.getTransferable();
 191        final boolean sent = status != Message.STATUS_RECEIVED;
 192        final boolean showUserNickname =
 193                message.getConversation().getMode() == Conversation.MODE_MULTI
 194                        && viewHolder instanceof StartBubbleMessageItemViewHolder;
 195        final String fileSize;
 196        if (message.isFileOrImage()
 197                || transferable != null
 198                || MessageUtils.unInitiatedButKnownSize(message)) {
 199            final FileParams params = message.getFileParams();
 200            fileSize = params.size != null ? UIHelper.filesizeToString(params.size) : null;
 201            if (message.getStatus() == Message.STATUS_SEND_FAILED
 202                    || (transferable != null
 203                            && (transferable.getStatus() == Transferable.STATUS_FAILED
 204                                    || transferable.getStatus()
 205                                            == Transferable.STATUS_CANCELLED))) {
 206                error = true;
 207            } else {
 208                error = message.getStatus() == Message.STATUS_SEND_FAILED;
 209            }
 210        } else {
 211            fileSize = null;
 212            error = message.getStatus() == Message.STATUS_SEND_FAILED;
 213        }
 214
 215        if (sent) {
 216            final @DrawableRes Integer receivedIndicator =
 217                    getMessageStatusAsDrawable(message, status);
 218            if (receivedIndicator == null) {
 219                viewHolder.indicatorReceived().setVisibility(View.INVISIBLE);
 220            } else {
 221                viewHolder.indicatorReceived().setImageResource(receivedIndicator);
 222                if (status == Message.STATUS_SEND_FAILED) {
 223                    setImageTintError(viewHolder.indicatorReceived());
 224                } else {
 225                    setImageTint(viewHolder.indicatorReceived(), bubbleColor);
 226                }
 227                viewHolder.indicatorReceived().setVisibility(View.VISIBLE);
 228            }
 229        } else {
 230            viewHolder.indicatorReceived().setVisibility(View.GONE);
 231        }
 232        final var additionalStatusInfo = getAdditionalStatusInfo(message, status);
 233
 234        if (error && sent) {
 235            viewHolder
 236                    .time()
 237                    .setTextColor(
 238                            MaterialColors.getColor(
 239                                    viewHolder.time(),
 240                                    com.google.android.material.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        MyLinkify.addLinks(body, true);
 581        if (highlightedTerm != null) {
 582            StylingHelper.highlight(viewHolder.messageBody(), body, highlightedTerm);
 583        }
 584        viewHolder.messageBody().setAutoLinkMask(0);
 585        viewHolder.messageBody().setText(body);
 586        viewHolder.messageBody().setMovementMethod(ClickableMovementMethod.getInstance());
 587    }
 588
 589    private void displayDownloadableMessage(
 590            final BubbleMessageItemViewHolder viewHolder,
 591            final Message message,
 592            final String text,
 593            final BubbleColor bubbleColor) {
 594        toggleWhisperInfo(viewHolder, message, bubbleColor);
 595        viewHolder.image().setVisibility(View.GONE);
 596        viewHolder.audioPlayer().setVisibility(View.GONE);
 597        viewHolder.downloadButton().setVisibility(View.VISIBLE);
 598        viewHolder.downloadButton().setText(text);
 599        final var attachment = Attachment.of(message);
 600        final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
 601        viewHolder.downloadButton().setIconResource(imageResource);
 602        viewHolder
 603                .downloadButton()
 604                .setOnClickListener(v -> ConversationFragment.downloadFile(activity, message));
 605    }
 606
 607    private void displayOpenableMessage(
 608            final BubbleMessageItemViewHolder viewHolder,
 609            final Message message,
 610            final BubbleColor bubbleColor) {
 611        toggleWhisperInfo(viewHolder, message, bubbleColor);
 612        viewHolder.image().setVisibility(View.GONE);
 613        viewHolder.audioPlayer().setVisibility(View.GONE);
 614        viewHolder.downloadButton().setVisibility(View.VISIBLE);
 615        viewHolder
 616                .downloadButton()
 617                .setText(
 618                        activity.getString(
 619                                R.string.open_x_file,
 620                                UIHelper.getFileDescriptionString(activity, message)));
 621        final var attachment = Attachment.of(message);
 622        final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
 623        viewHolder.downloadButton().setIconResource(imageResource);
 624        viewHolder.downloadButton().setOnClickListener(v -> openDownloadable(message));
 625    }
 626
 627    private void displayLocationMessage(
 628            final BubbleMessageItemViewHolder viewHolder,
 629            final Message message,
 630            final BubbleColor bubbleColor) {
 631        toggleWhisperInfo(viewHolder, message, bubbleColor);
 632        viewHolder.image().setVisibility(View.GONE);
 633        viewHolder.audioPlayer().setVisibility(View.GONE);
 634        viewHolder.downloadButton().setVisibility(View.VISIBLE);
 635        viewHolder.downloadButton().setText(R.string.show_location);
 636        final var attachment = Attachment.of(message);
 637        final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
 638        viewHolder.downloadButton().setIconResource(imageResource);
 639        viewHolder.downloadButton().setOnClickListener(v -> showLocation(message));
 640    }
 641
 642    private void displayAudioMessage(
 643            final BubbleMessageItemViewHolder viewHolder,
 644            Message message,
 645            final BubbleColor bubbleColor) {
 646        toggleWhisperInfo(viewHolder, message, bubbleColor);
 647        viewHolder.image().setVisibility(View.GONE);
 648        viewHolder.downloadButton().setVisibility(View.GONE);
 649        final RelativeLayout audioPlayer = viewHolder.audioPlayer();
 650        audioPlayer.setVisibility(View.VISIBLE);
 651        AudioPlayer.ViewHolder.get(audioPlayer).setBubbleColor(bubbleColor);
 652        this.audioPlayer.init(audioPlayer, message);
 653    }
 654
 655    private void displayMediaPreviewMessage(
 656            final BubbleMessageItemViewHolder viewHolder,
 657            final Message message,
 658            final BubbleColor bubbleColor) {
 659        toggleWhisperInfo(viewHolder, message, bubbleColor);
 660        viewHolder.downloadButton().setVisibility(View.GONE);
 661        viewHolder.audioPlayer().setVisibility(View.GONE);
 662        viewHolder.image().setVisibility(View.VISIBLE);
 663        final FileParams params = message.getFileParams();
 664        final float target = activity.getResources().getDimension(R.dimen.image_preview_width);
 665        final int scaledW;
 666        final int scaledH;
 667        if (Math.max(params.height, params.width) * metrics.density <= target) {
 668            scaledW = (int) (params.width * metrics.density);
 669            scaledH = (int) (params.height * metrics.density);
 670        } else if (Math.max(params.height, params.width) <= target) {
 671            scaledW = params.width;
 672            scaledH = params.height;
 673        } else if (params.width <= params.height) {
 674            scaledW = (int) (params.width / ((double) params.height / target));
 675            scaledH = (int) target;
 676        } else {
 677            scaledW = (int) target;
 678            scaledH = (int) (params.height / ((double) params.width / target));
 679        }
 680        final LinearLayout.LayoutParams layoutParams =
 681                new LinearLayout.LayoutParams(scaledW, scaledH);
 682        viewHolder.image().setLayoutParams(layoutParams);
 683        activity.loadBitmap(message, viewHolder.image());
 684        viewHolder.image().setOnClickListener(v -> openDownloadable(message));
 685    }
 686
 687    private void toggleWhisperInfo(
 688            final BubbleMessageItemViewHolder viewHolder,
 689            final Message message,
 690            final BubbleColor bubbleColor) {
 691        if (message.isPrivateMessage()) {
 692            final String privateMarker;
 693            if (message.getStatus() <= Message.STATUS_RECEIVED) {
 694                privateMarker = activity.getString(R.string.private_message);
 695            } else {
 696                Jid cp = message.getCounterpart();
 697                privateMarker =
 698                        activity.getString(
 699                                R.string.private_message_to,
 700                                Strings.nullToEmpty(cp == null ? null : cp.getResource()));
 701            }
 702            final SpannableString body = new SpannableString(privateMarker);
 703            body.setSpan(
 704                    new ForegroundColorSpan(
 705                            bubbleToOnSurfaceVariant(viewHolder.messageBody(), bubbleColor)),
 706                    0,
 707                    privateMarker.length(),
 708                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 709            body.setSpan(
 710                    new StyleSpan(Typeface.BOLD),
 711                    0,
 712                    privateMarker.length(),
 713                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 714            viewHolder.messageBody().setText(body);
 715            viewHolder.messageBody().setTypeface(null, Typeface.NORMAL);
 716            viewHolder.messageBody().setVisibility(View.VISIBLE);
 717        } else {
 718            viewHolder.messageBody().setVisibility(View.GONE);
 719        }
 720    }
 721
 722    private void loadMoreMessages(final Conversation conversation) {
 723        conversation.setLastClearHistory(0, null);
 724        activity.xmppConnectionService.updateConversation(conversation);
 725        conversation.setHasMessagesLeftOnServer(true);
 726        conversation.setFirstMamReference(null);
 727        long timestamp = conversation.getLastMessageTransmitted().getTimestamp();
 728        if (timestamp == 0) {
 729            timestamp = System.currentTimeMillis();
 730        }
 731        conversation.messagesLoaded.set(true);
 732        MessageArchiveService.Query query =
 733                activity.xmppConnectionService
 734                        .getMessageArchiveService()
 735                        .query(conversation, new MamReference(0), timestamp, false);
 736        if (query != null) {
 737            Toast.makeText(activity, R.string.fetching_history_from_server, Toast.LENGTH_LONG)
 738                    .show();
 739        } else {
 740            Toast.makeText(
 741                            activity,
 742                            R.string.not_fetching_history_retention_period,
 743                            Toast.LENGTH_SHORT)
 744                    .show();
 745        }
 746    }
 747
 748    private MessageItemViewHolder getViewHolder(
 749            final View view, final @NonNull ViewGroup parent, final int type) {
 750        if (view != null && view.getTag() instanceof MessageItemViewHolder messageItemViewHolder) {
 751            return messageItemViewHolder;
 752        } else {
 753            final MessageItemViewHolder viewHolder =
 754                    switch (type) {
 755                        case RTP_SESSION ->
 756                                new RtpSessionMessageItemViewHolder(
 757                                        DataBindingUtil.inflate(
 758                                                LayoutInflater.from(parent.getContext()),
 759                                                R.layout.item_message_rtp_session,
 760                                                parent,
 761                                                false));
 762                        case DATE_SEPARATOR ->
 763                                new DateSeperatorMessageItemViewHolder(
 764                                        DataBindingUtil.inflate(
 765                                                LayoutInflater.from(parent.getContext()),
 766                                                R.layout.item_message_date_bubble,
 767                                                parent,
 768                                                false));
 769                        case STATUS ->
 770                                new StatusMessageItemViewHolder(
 771                                        DataBindingUtil.inflate(
 772                                                LayoutInflater.from(parent.getContext()),
 773                                                R.layout.item_message_status,
 774                                                parent,
 775                                                false));
 776                        case END ->
 777                                new EndBubbleMessageItemViewHolder(
 778                                        DataBindingUtil.inflate(
 779                                                LayoutInflater.from(parent.getContext()),
 780                                                R.layout.item_message_end,
 781                                                parent,
 782                                                false));
 783                        case START ->
 784                                new StartBubbleMessageItemViewHolder(
 785                                        DataBindingUtil.inflate(
 786                                                LayoutInflater.from(parent.getContext()),
 787                                                R.layout.item_message_start,
 788                                                parent,
 789                                                false));
 790                        default -> throw new AssertionError("Unable to create ViewHolder for type");
 791                    };
 792            viewHolder.itemView.setTag(viewHolder);
 793            return viewHolder;
 794        }
 795    }
 796
 797    @NonNull
 798    @Override
 799    public View getView(final int position, final View view, final @NonNull ViewGroup parent) {
 800        final Message message = getItem(position);
 801        final int type = getItemViewType(message, bubbleDesign.alignStart);
 802        final MessageItemViewHolder viewHolder = getViewHolder(view, parent, type);
 803
 804        if (type == DATE_SEPARATOR
 805                && viewHolder instanceof DateSeperatorMessageItemViewHolder messageItemViewHolder) {
 806            return render(message, messageItemViewHolder);
 807        }
 808
 809        if (type == RTP_SESSION
 810                && viewHolder instanceof RtpSessionMessageItemViewHolder messageItemViewHolder) {
 811            return render(message, messageItemViewHolder);
 812        }
 813
 814        if (type == STATUS
 815                && viewHolder instanceof StatusMessageItemViewHolder messageItemViewHolder) {
 816            return render(message, messageItemViewHolder);
 817        }
 818
 819        if ((type == END || type == START)
 820                && viewHolder instanceof BubbleMessageItemViewHolder messageItemViewHolder) {
 821            return render(position, message, messageItemViewHolder);
 822        }
 823
 824        throw new AssertionError();
 825    }
 826
 827    private View render(
 828            final int position,
 829            final Message message,
 830            final BubbleMessageItemViewHolder viewHolder) {
 831        final boolean omemoEncryption = message.getEncryption() == Message.ENCRYPTION_AXOLOTL;
 832        final boolean isInValidSession =
 833                message.isValidInSession() && (!omemoEncryption || message.isTrusted());
 834        final Conversational conversation = message.getConversation();
 835        final Account account = conversation.getAccount();
 836
 837        final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
 838        final boolean received = message.getStatus() == Message.STATUS_RECEIVED;
 839        final BubbleColor bubbleColor;
 840        if (received) {
 841            if (isInValidSession) {
 842                bubbleColor = colorfulBackground ? BubbleColor.SECONDARY : BubbleColor.SURFACE;
 843            } else {
 844                bubbleColor = BubbleColor.WARNING;
 845            }
 846        } else {
 847            bubbleColor = colorfulBackground ? BubbleColor.TERTIARY : BubbleColor.SURFACE_HIGH;
 848        }
 849
 850        final var mergeIntoTop = mergeIntoTop(position, message);
 851        final var mergeIntoBottom = mergeIntoBottom(position, message);
 852        final var showAvatar =
 853                bubbleDesign.showAvatars
 854                        || (viewHolder instanceof StartBubbleMessageItemViewHolder
 855                                && message.getConversation().getMode() == Conversation.MODE_MULTI);
 856        setBubblePadding(viewHolder.root(), mergeIntoTop, mergeIntoBottom);
 857        if (showAvatar) {
 858            final var requiresAvatar =
 859                    viewHolder instanceof StartBubbleMessageItemViewHolder
 860                            ? !mergeIntoTop
 861                            : !mergeIntoBottom;
 862            setRequiresAvatar(viewHolder, requiresAvatar);
 863            AvatarWorkerTask.loadAvatar(message, viewHolder.contactPicture(), R.dimen.avatar);
 864        } else {
 865            viewHolder.contactPicture().setVisibility(View.GONE);
 866        }
 867        setAvatarDistance(viewHolder.messageBox(), viewHolder.getClass(), showAvatar);
 868        viewHolder.messageBox().setClipToOutline(true);
 869
 870        resetClickListener(viewHolder.messageBox(), viewHolder.messageBody());
 871
 872        viewHolder
 873                .contactPicture()
 874                .setOnClickListener(
 875                        v -> {
 876                            if (MessageAdapter.this.mOnContactPictureClickedListener != null) {
 877                                MessageAdapter.this.mOnContactPictureClickedListener
 878                                        .onContactPictureClicked(message);
 879                            }
 880                        });
 881        viewHolder
 882                .contactPicture()
 883                .setOnLongClickListener(
 884                        v -> {
 885                            if (MessageAdapter.this.mOnContactPictureLongClickedListener != null) {
 886                                MessageAdapter.this.mOnContactPictureLongClickedListener
 887                                        .onContactPictureLongClicked(v, message);
 888                                return true;
 889                            } else {
 890                                return false;
 891                            }
 892                        });
 893
 894        final Transferable transferable = message.getTransferable();
 895        final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
 896        if (unInitiatedButKnownSize
 897                || message.isDeleted()
 898                || (transferable != null
 899                        && transferable.getStatus() != Transferable.STATUS_UPLOADING)) {
 900            if (unInitiatedButKnownSize
 901                    || transferable != null
 902                            && transferable.getStatus() == Transferable.STATUS_OFFER) {
 903                displayDownloadableMessage(
 904                        viewHolder,
 905                        message,
 906                        activity.getString(
 907                                R.string.download_x_file,
 908                                UIHelper.getFileDescriptionString(activity, message)),
 909                        bubbleColor);
 910            } else if (transferable != null
 911                    && transferable.getStatus() == Transferable.STATUS_OFFER_CHECK_FILESIZE) {
 912                displayDownloadableMessage(
 913                        viewHolder,
 914                        message,
 915                        activity.getString(
 916                                R.string.check_x_filesize,
 917                                UIHelper.getFileDescriptionString(activity, message)),
 918                        bubbleColor);
 919            } else {
 920                displayInfoMessage(
 921                        viewHolder,
 922                        UIHelper.getMessagePreview(activity, message).first,
 923                        bubbleColor);
 924            }
 925        } else if (message.isFileOrImage()
 926                && message.getEncryption() != Message.ENCRYPTION_PGP
 927                && message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED) {
 928            if (message.getFileParams().width > 0 && message.getFileParams().height > 0) {
 929                displayMediaPreviewMessage(viewHolder, message, bubbleColor);
 930            } else if (message.getFileParams().runtime > 0) {
 931                displayAudioMessage(viewHolder, message, bubbleColor);
 932            } else {
 933                displayOpenableMessage(viewHolder, message, bubbleColor);
 934            }
 935        } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
 936            if (account.isPgpDecryptionServiceConnected()) {
 937                if (conversation instanceof Conversation
 938                        && !account.hasPendingPgpIntent((Conversation) conversation)) {
 939                    displayInfoMessage(
 940                            viewHolder,
 941                            activity.getString(R.string.message_decrypting),
 942                            bubbleColor);
 943                } else {
 944                    displayInfoMessage(
 945                            viewHolder, activity.getString(R.string.pgp_message), bubbleColor);
 946                }
 947            } else {
 948                displayInfoMessage(
 949                        viewHolder, activity.getString(R.string.install_openkeychain), bubbleColor);
 950                viewHolder.messageBox().setOnClickListener(this::promptOpenKeychainInstall);
 951                viewHolder.messageBody().setOnClickListener(this::promptOpenKeychainInstall);
 952            }
 953        } else if (message.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) {
 954            displayInfoMessage(
 955                    viewHolder, activity.getString(R.string.decryption_failed), bubbleColor);
 956        } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE) {
 957            displayInfoMessage(
 958                    viewHolder,
 959                    activity.getString(R.string.not_encrypted_for_this_device),
 960                    bubbleColor);
 961        } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_FAILED) {
 962            displayInfoMessage(
 963                    viewHolder, activity.getString(R.string.omemo_decryption_failed), bubbleColor);
 964        } else {
 965            if (message.isGeoUri()) {
 966                displayLocationMessage(viewHolder, message, bubbleColor);
 967            } else if (message.bodyIsOnlyEmojis() && message.getType() != Message.TYPE_PRIVATE) {
 968                displayEmojiMessage(viewHolder, message.getBody().trim(), bubbleColor);
 969            } else if (message.treatAsDownloadable()) {
 970                try {
 971                    final URI uri = new URI(message.getBody());
 972                    displayDownloadableMessage(
 973                            viewHolder,
 974                            message,
 975                            activity.getString(
 976                                    R.string.check_x_filesize_on_host,
 977                                    UIHelper.getFileDescriptionString(activity, message),
 978                                    uri.getHost()),
 979                            bubbleColor);
 980                } catch (Exception e) {
 981                    displayDownloadableMessage(
 982                            viewHolder,
 983                            message,
 984                            activity.getString(
 985                                    R.string.check_x_filesize,
 986                                    UIHelper.getFileDescriptionString(activity, message)),
 987                            bubbleColor);
 988                }
 989            } else {
 990                displayTextMessage(viewHolder, message, bubbleColor);
 991            }
 992        }
 993
 994        setBackgroundTint(viewHolder.messageBox(), bubbleColor);
 995        setTextColor(viewHolder.messageBody(), bubbleColor);
 996
 997        if (received && viewHolder instanceof StartBubbleMessageItemViewHolder startViewHolder) {
 998            setTextColor(startViewHolder.encryption(), bubbleColor);
 999            if (isInValidSession) {
1000                startViewHolder.encryption().setVisibility(View.GONE);
1001            } else {
1002                startViewHolder.encryption().setVisibility(View.VISIBLE);
1003                if (omemoEncryption && !message.isTrusted()) {
1004                    startViewHolder.encryption().setText(R.string.not_trusted);
1005                } else {
1006                    startViewHolder
1007                            .encryption()
1008                            .setText(CryptoHelper.encryptionTypeToText(message.getEncryption()));
1009                }
1010            }
1011            BindingAdapters.setReactionsOnReceived(
1012                    viewHolder.reactions(),
1013                    message.getAggregatedReactions(),
1014                    reactions -> sendReactions(message, reactions),
1015                    emoji -> showDetailedReaction(message, emoji),
1016                    () -> addReaction(message));
1017        } else {
1018            if (viewHolder instanceof StartBubbleMessageItemViewHolder startViewHolder) {
1019                startViewHolder.encryption().setVisibility(View.GONE);
1020            }
1021            BindingAdapters.setReactionsOnSent(
1022                    viewHolder.reactions(),
1023                    message.getAggregatedReactions(),
1024                    reactions -> sendReactions(message, reactions),
1025                    emoji -> showDetailedReaction(message, emoji));
1026        }
1027
1028        displayStatus(viewHolder, message, bubbleColor);
1029        return viewHolder.root();
1030    }
1031
1032    private View render(
1033            final Message message, final DateSeperatorMessageItemViewHolder viewHolder) {
1034        final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
1035        if (UIHelper.today(message.getTimeSent())) {
1036            viewHolder.binding.messageBody.setText(R.string.today);
1037        } else if (UIHelper.yesterday(message.getTimeSent())) {
1038            viewHolder.binding.messageBody.setText(R.string.yesterday);
1039        } else {
1040            viewHolder.binding.messageBody.setText(
1041                    DateUtils.formatDateTime(
1042                            activity,
1043                            message.getTimeSent(),
1044                            DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR));
1045        }
1046        if (colorfulBackground) {
1047            setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.PRIMARY);
1048            setTextColor(viewHolder.binding.messageBody, BubbleColor.PRIMARY);
1049        } else {
1050            setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.SURFACE_HIGH);
1051            setTextColor(viewHolder.binding.messageBody, BubbleColor.SURFACE_HIGH);
1052        }
1053        return viewHolder.binding.getRoot();
1054    }
1055
1056    private View render(final Message message, final RtpSessionMessageItemViewHolder viewHolder) {
1057        final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
1058        final boolean received = message.getStatus() <= Message.STATUS_RECEIVED;
1059        final RtpSessionStatus rtpSessionStatus = RtpSessionStatus.of(message.getBody());
1060        final long duration = rtpSessionStatus.duration;
1061        if (received) {
1062            if (duration > 0) {
1063                viewHolder.binding.messageBody.setText(
1064                        activity.getString(
1065                                R.string.incoming_call_duration_timestamp,
1066                                TimeFrameUtils.resolve(activity, duration),
1067                                UIHelper.readableTimeDifferenceFull(
1068                                        activity, message.getTimeSent())));
1069            } else if (rtpSessionStatus.successful) {
1070                viewHolder.binding.messageBody.setText(R.string.incoming_call);
1071            } else {
1072                viewHolder.binding.messageBody.setText(
1073                        activity.getString(
1074                                R.string.missed_call_timestamp,
1075                                UIHelper.readableTimeDifferenceFull(
1076                                        activity, message.getTimeSent())));
1077            }
1078        } else {
1079            if (duration > 0) {
1080                viewHolder.binding.messageBody.setText(
1081                        activity.getString(
1082                                R.string.outgoing_call_duration_timestamp,
1083                                TimeFrameUtils.resolve(activity, duration),
1084                                UIHelper.readableTimeDifferenceFull(
1085                                        activity, message.getTimeSent())));
1086            } else {
1087                viewHolder.binding.messageBody.setText(
1088                        activity.getString(
1089                                R.string.outgoing_call_timestamp,
1090                                UIHelper.readableTimeDifferenceFull(
1091                                        activity, message.getTimeSent())));
1092            }
1093        }
1094        if (colorfulBackground) {
1095            setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.SECONDARY);
1096            setTextColor(viewHolder.binding.messageBody, BubbleColor.SECONDARY);
1097            setImageTint(viewHolder.binding.indicatorReceived, BubbleColor.SECONDARY);
1098        } else {
1099            setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.SURFACE_HIGH);
1100            setTextColor(viewHolder.binding.messageBody, BubbleColor.SURFACE_HIGH);
1101            setImageTint(viewHolder.binding.indicatorReceived, BubbleColor.SURFACE_HIGH);
1102        }
1103        viewHolder.binding.indicatorReceived.setImageResource(
1104                RtpSessionStatus.getDrawable(received, rtpSessionStatus.successful));
1105        return viewHolder.binding.getRoot();
1106    }
1107
1108    private View render(final Message message, final StatusMessageItemViewHolder viewHolder) {
1109        final var conversation = message.getConversation();
1110        if ("LOAD_MORE".equals(message.getBody())) {
1111            viewHolder.binding.statusMessage.setVisibility(View.GONE);
1112            viewHolder.binding.messagePhoto.setVisibility(View.GONE);
1113            viewHolder.binding.loadMoreMessages.setVisibility(View.VISIBLE);
1114            viewHolder.binding.loadMoreMessages.setOnClickListener(
1115                    v -> loadMoreMessages((Conversation) message.getConversation()));
1116        } else {
1117            viewHolder.binding.statusMessage.setVisibility(View.VISIBLE);
1118            viewHolder.binding.loadMoreMessages.setVisibility(View.GONE);
1119            viewHolder.binding.statusMessage.setText(message.getBody());
1120            boolean showAvatar;
1121            if (conversation.getMode() == Conversation.MODE_SINGLE) {
1122                showAvatar = true;
1123                AvatarWorkerTask.loadAvatar(
1124                        message, viewHolder.binding.messagePhoto, R.dimen.avatar_on_status_message);
1125            } else if (message.getCounterpart() != null
1126                    || message.getTrueCounterpart() != null
1127                    || (message.getCounterparts() != null
1128                            && !message.getCounterparts().isEmpty())) {
1129                showAvatar = true;
1130                AvatarWorkerTask.loadAvatar(
1131                        message, viewHolder.binding.messagePhoto, R.dimen.avatar_on_status_message);
1132            } else {
1133                showAvatar = false;
1134            }
1135            if (showAvatar) {
1136                viewHolder.binding.messagePhoto.setAlpha(0.5f);
1137                viewHolder.binding.messagePhoto.setVisibility(View.VISIBLE);
1138            } else {
1139                viewHolder.binding.messagePhoto.setVisibility(View.GONE);
1140            }
1141        }
1142        return viewHolder.binding.getRoot();
1143    }
1144
1145    private void setAvatarDistance(
1146            final LinearLayout messageBox,
1147            final Class<? extends BubbleMessageItemViewHolder> clazz,
1148            final boolean showAvatar) {
1149        final ViewGroup.MarginLayoutParams layoutParams =
1150                (ViewGroup.MarginLayoutParams) messageBox.getLayoutParams();
1151        if (showAvatar) {
1152            final var resources = messageBox.getResources();
1153            if (clazz == StartBubbleMessageItemViewHolder.class) {
1154                layoutParams.setMarginStart(
1155                        resources.getDimensionPixelSize(R.dimen.bubble_avatar_distance));
1156                layoutParams.setMarginEnd(0);
1157            } else if (clazz == EndBubbleMessageItemViewHolder.class) {
1158                layoutParams.setMarginStart(0);
1159                layoutParams.setMarginEnd(
1160                        resources.getDimensionPixelSize(R.dimen.bubble_avatar_distance));
1161            } else {
1162                throw new AssertionError("Avatar distances are not available on this view type");
1163            }
1164        } else {
1165            layoutParams.setMarginStart(0);
1166            layoutParams.setMarginEnd(0);
1167        }
1168        messageBox.setLayoutParams(layoutParams);
1169    }
1170
1171    private void setBubblePadding(
1172            final ConstraintLayout root,
1173            final boolean mergeIntoTop,
1174            final boolean mergeIntoBottom) {
1175        final var resources = root.getResources();
1176        final var horizontal = resources.getDimensionPixelSize(R.dimen.bubble_horizontal_padding);
1177        final int top =
1178                resources.getDimensionPixelSize(
1179                        mergeIntoTop
1180                                ? R.dimen.bubble_vertical_padding_minimum
1181                                : R.dimen.bubble_vertical_padding);
1182        final int bottom =
1183                resources.getDimensionPixelSize(
1184                        mergeIntoBottom
1185                                ? R.dimen.bubble_vertical_padding_minimum
1186                                : R.dimen.bubble_vertical_padding);
1187        root.setPadding(horizontal, top, horizontal, bottom);
1188    }
1189
1190    private void setRequiresAvatar(
1191            final BubbleMessageItemViewHolder viewHolder, final boolean requiresAvatar) {
1192        final var layoutParams = viewHolder.contactPicture().getLayoutParams();
1193        if (requiresAvatar) {
1194            final var resources = viewHolder.contactPicture().getResources();
1195            final var avatarSize = resources.getDimensionPixelSize(R.dimen.bubble_avatar_size);
1196            layoutParams.height = avatarSize;
1197            viewHolder.contactPicture().setVisibility(View.VISIBLE);
1198            viewHolder.messageBox().setMinimumHeight(avatarSize);
1199        } else {
1200            layoutParams.height = 0;
1201            viewHolder.contactPicture().setVisibility(View.INVISIBLE);
1202            viewHolder.messageBox().setMinimumHeight(0);
1203        }
1204        viewHolder.contactPicture().setLayoutParams(layoutParams);
1205    }
1206
1207    private boolean mergeIntoTop(final int position, final Message message) {
1208        if (position < 0) {
1209            return false;
1210        }
1211        final var top = getItem(position - 1);
1212        return merge(top, message);
1213    }
1214
1215    private boolean mergeIntoBottom(final int position, final Message message) {
1216        final Message bottom;
1217        try {
1218            bottom = getItem(position + 1);
1219        } catch (final IndexOutOfBoundsException e) {
1220            return false;
1221        }
1222        return merge(message, bottom);
1223    }
1224
1225    private static boolean merge(final Message a, final Message b) {
1226        if (getItemViewType(a, false) != getItemViewType(b, false)) {
1227            return false;
1228        }
1229        final var receivedA = a.getStatus() == Message.STATUS_RECEIVED;
1230        final var receivedB = b.getStatus() == Message.STATUS_RECEIVED;
1231        if (receivedA != receivedB) {
1232            return false;
1233        }
1234        if (a.getConversation().getMode() == Conversation.MODE_MULTI
1235                && a.getStatus() == Message.STATUS_RECEIVED) {
1236            final var occupantIdA = a.getOccupantId();
1237            final var occupantIdB = b.getOccupantId();
1238            if (occupantIdA != null && occupantIdB != null) {
1239                if (!occupantIdA.equals(occupantIdB)) {
1240                    return false;
1241                }
1242            }
1243            final var counterPartA = a.getCounterpart();
1244            final var counterPartB = b.getCounterpart();
1245            if (counterPartA == null || !counterPartA.equals(counterPartB)) {
1246                return false;
1247            }
1248        }
1249        return b.getTimeSent() - a.getTimeSent() <= Config.MESSAGE_MERGE_WINDOW;
1250    }
1251
1252    private boolean showDetailedReaction(final Message message, final String emoji) {
1253        final var c = message.getConversation();
1254        if (c instanceof Conversation conversation && c.getMode() == Conversational.MODE_MULTI) {
1255            final var reactions =
1256                    Collections2.filter(
1257                            message.getReactions(), r -> r.normalizedReaction().equals(emoji));
1258            final var mucOptions = conversation.getMucOptions();
1259            final var users = mucOptions.findUsers(reactions);
1260            if (users.isEmpty()) {
1261                return true;
1262            }
1263            final MaterialAlertDialogBuilder dialogBuilder =
1264                    new MaterialAlertDialogBuilder(activity);
1265            dialogBuilder.setTitle(emoji);
1266            dialogBuilder.setMessage(UIHelper.concatNames(users));
1267            dialogBuilder.create().show();
1268            return true;
1269        } else {
1270            return false;
1271        }
1272    }
1273
1274    private void sendReactions(final Message message, final Collection<String> reactions) {
1275        if (activity.xmppConnectionService.sendReactions(message, reactions)) {
1276            return;
1277        }
1278        Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
1279    }
1280
1281    private void addReaction(final Message message) {
1282        activity.addReaction(
1283                message,
1284                reactions -> {
1285                    if (activity.xmppConnectionService.sendReactions(message, reactions)) {
1286                        return;
1287                    }
1288                    Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG)
1289                            .show();
1290                });
1291    }
1292
1293    private void promptOpenKeychainInstall(View view) {
1294        activity.showInstallPgpDialog();
1295    }
1296
1297    public FileBackend getFileBackend() {
1298        return activity.xmppConnectionService.getFileBackend();
1299    }
1300
1301    public void stopAudioPlayer() {
1302        audioPlayer.stop();
1303    }
1304
1305    public void unregisterListenerInAudioPlayer() {
1306        audioPlayer.unregisterListener();
1307    }
1308
1309    public void startStopPending() {
1310        audioPlayer.startStopPending();
1311    }
1312
1313    public void openDownloadable(Message message) {
1314        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
1315                && ContextCompat.checkSelfPermission(
1316                                activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)
1317                        != PackageManager.PERMISSION_GRANTED) {
1318            ConversationFragment.registerPendingMessage(activity, message);
1319            ActivityCompat.requestPermissions(
1320                    activity,
1321                    new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE},
1322                    ConversationsActivity.REQUEST_OPEN_MESSAGE);
1323            return;
1324        }
1325        final DownloadableFile file =
1326                activity.xmppConnectionService.getFileBackend().getFile(message);
1327        ViewUtil.view(activity, file);
1328    }
1329
1330    private void showLocation(Message message) {
1331        for (Intent intent : GeoHelper.createGeoIntentsFromMessage(activity, message)) {
1332            if (intent.resolveActivity(getContext().getPackageManager()) != null) {
1333                getContext().startActivity(intent);
1334                return;
1335            }
1336        }
1337        Toast.makeText(
1338                        activity,
1339                        R.string.no_application_found_to_display_location,
1340                        Toast.LENGTH_SHORT)
1341                .show();
1342    }
1343
1344    public void updatePreferences() {
1345        final AppSettings appSettings = new AppSettings(activity);
1346        this.bubbleDesign =
1347                new BubbleDesign(
1348                        appSettings.isColorfulChatBubbles(),
1349                        appSettings.isAlignStart(),
1350                        appSettings.isLargeFont(),
1351                        appSettings.isShowAvatars());
1352    }
1353
1354    public void setHighlightedTerm(List<String> terms) {
1355        this.highlightedTerm = terms == null ? null : StylingHelper.filterHighlightedWords(terms);
1356    }
1357
1358    public interface OnContactPictureClicked {
1359        void onContactPictureClicked(Message message);
1360    }
1361
1362    public interface OnContactPictureLongClicked {
1363        void onContactPictureLongClicked(View v, Message message);
1364    }
1365
1366    private static void setBackgroundTint(final LinearLayout view, final BubbleColor bubbleColor) {
1367        view.setBackgroundTintList(bubbleToColorStateList(view, bubbleColor));
1368    }
1369
1370    private static ColorStateList bubbleToColorStateList(
1371            final View view, final BubbleColor bubbleColor) {
1372        final @AttrRes int colorAttributeResId =
1373                switch (bubbleColor) {
1374                    case SURFACE ->
1375                            Activities.isNightMode(view.getContext())
1376                                    ? com.google.android.material.R.attr.colorSurfaceContainerHigh
1377                                    : com.google.android.material.R.attr.colorSurfaceContainerLow;
1378                    case SURFACE_HIGH ->
1379                            Activities.isNightMode(view.getContext())
1380                                    ? com.google.android.material.R.attr
1381                                            .colorSurfaceContainerHighest
1382                                    : com.google.android.material.R.attr.colorSurfaceContainerHigh;
1383                    case PRIMARY -> com.google.android.material.R.attr.colorPrimaryContainer;
1384                    case SECONDARY -> com.google.android.material.R.attr.colorSecondaryContainer;
1385                    case TERTIARY -> com.google.android.material.R.attr.colorTertiaryContainer;
1386                    case WARNING -> com.google.android.material.R.attr.colorErrorContainer;
1387                };
1388        return ColorStateList.valueOf(MaterialColors.getColor(view, colorAttributeResId));
1389    }
1390
1391    public static void setImageTint(final ImageView imageView, final BubbleColor bubbleColor) {
1392        ImageViewCompat.setImageTintList(
1393                imageView, bubbleToOnSurfaceColorStateList(imageView, bubbleColor));
1394    }
1395
1396    public static void setImageTintError(final ImageView imageView) {
1397        ImageViewCompat.setImageTintList(
1398                imageView,
1399                ColorStateList.valueOf(
1400                        MaterialColors.getColor(
1401                                imageView, com.google.android.material.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(
1410                            textView, com.google.android.material.R.attr.colorPrimary));
1411        } else {
1412            textView.setLinkTextColor(color);
1413        }
1414    }
1415
1416    private static void setTextSize(final TextView textView, final boolean largeFont) {
1417        if (largeFont) {
1418            textView.setTextAppearance(
1419                    com.google.android.material.R.style.TextAppearance_Material3_BodyLarge);
1420        } else {
1421            textView.setTextAppearance(
1422                    com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
1423        }
1424    }
1425
1426    private static @ColorInt int bubbleToOnSurfaceVariant(
1427            final View view, final BubbleColor bubbleColor) {
1428        final @AttrRes int colorAttributeResId;
1429        if (BubbleColor.SURFACES.contains(bubbleColor)) {
1430            colorAttributeResId = com.google.android.material.R.attr.colorOnSurfaceVariant;
1431        } else {
1432            colorAttributeResId = bubbleToOnSurface(bubbleColor);
1433        }
1434        return MaterialColors.getColor(view, colorAttributeResId);
1435    }
1436
1437    private static @ColorInt int bubbleToOnSurfaceColor(
1438            final View view, final BubbleColor bubbleColor) {
1439        return MaterialColors.getColor(view, bubbleToOnSurface(bubbleColor));
1440    }
1441
1442    public static ColorStateList bubbleToOnSurfaceColorStateList(
1443            final View view, final BubbleColor bubbleColor) {
1444        return ColorStateList.valueOf(bubbleToOnSurfaceColor(view, bubbleColor));
1445    }
1446
1447    private static @AttrRes int bubbleToOnSurface(final BubbleColor bubbleColor) {
1448        return switch (bubbleColor) {
1449            case SURFACE, SURFACE_HIGH -> com.google.android.material.R.attr.colorOnSurface;
1450            case PRIMARY -> com.google.android.material.R.attr.colorOnPrimaryContainer;
1451            case SECONDARY -> com.google.android.material.R.attr.colorOnSecondaryContainer;
1452            case TERTIARY -> com.google.android.material.R.attr.colorOnTertiaryContainer;
1453            case WARNING -> com.google.android.material.R.attr.colorOnErrorContainer;
1454        };
1455    }
1456
1457    public enum BubbleColor {
1458        SURFACE,
1459        SURFACE_HIGH,
1460        PRIMARY,
1461        SECONDARY,
1462        TERTIARY,
1463        WARNING;
1464
1465        private static final Collection<BubbleColor> SURFACES =
1466                Arrays.asList(BubbleColor.SURFACE, BubbleColor.SURFACE_HIGH);
1467    }
1468
1469    private static class BubbleDesign {
1470        public final boolean colorfulChatBubbles;
1471        public final boolean alignStart;
1472        public final boolean largeFont;
1473        public final boolean showAvatars;
1474
1475        private BubbleDesign(
1476                final boolean colorfulChatBubbles,
1477                final boolean alignStart,
1478                final boolean largeFont,
1479                final boolean showAvatars) {
1480            this.colorfulChatBubbles = colorfulChatBubbles;
1481            this.alignStart = alignStart;
1482            this.largeFont = largeFont;
1483            this.showAvatars = showAvatars;
1484        }
1485    }
1486
1487    private abstract static class MessageItemViewHolder /*extends RecyclerView.ViewHolder*/ {
1488
1489        private View itemView;
1490
1491        private MessageItemViewHolder(@NonNull View itemView) {
1492            this.itemView = itemView;
1493        }
1494    }
1495
1496    private abstract static class BubbleMessageItemViewHolder extends MessageItemViewHolder {
1497
1498        private BubbleMessageItemViewHolder(@NonNull View itemView) {
1499            super(itemView);
1500        }
1501
1502        public abstract ConstraintLayout root();
1503
1504        protected abstract ImageView indicatorEdit();
1505
1506        protected abstract RelativeLayout audioPlayer();
1507
1508        protected abstract LinearLayout messageBox();
1509
1510        protected abstract MaterialButton downloadButton();
1511
1512        protected abstract ImageView image();
1513
1514        protected abstract ImageView indicatorSecurity();
1515
1516        protected abstract ImageView indicatorReceived();
1517
1518        protected abstract TextView time();
1519
1520        protected abstract TextView messageBody();
1521
1522        protected abstract ImageView contactPicture();
1523
1524        protected abstract ChipGroup reactions();
1525    }
1526
1527    private static class StartBubbleMessageItemViewHolder extends BubbleMessageItemViewHolder {
1528
1529        private final ItemMessageStartBinding binding;
1530
1531        public StartBubbleMessageItemViewHolder(@NonNull ItemMessageStartBinding binding) {
1532            super(binding.getRoot());
1533            this.binding = binding;
1534        }
1535
1536        @Override
1537        public ConstraintLayout root() {
1538            return (ConstraintLayout) this.binding.getRoot();
1539        }
1540
1541        @Override
1542        protected ImageView indicatorEdit() {
1543            return this.binding.editIndicator;
1544        }
1545
1546        @Override
1547        protected RelativeLayout audioPlayer() {
1548            return this.binding.messageContent.audioPlayer;
1549        }
1550
1551        @Override
1552        protected LinearLayout messageBox() {
1553            return this.binding.messageBox;
1554        }
1555
1556        @Override
1557        protected MaterialButton downloadButton() {
1558            return this.binding.messageContent.downloadButton;
1559        }
1560
1561        @Override
1562        protected ImageView image() {
1563            return this.binding.messageContent.messageImage;
1564        }
1565
1566        protected ImageView indicatorSecurity() {
1567            return this.binding.securityIndicator;
1568        }
1569
1570        @Override
1571        protected ImageView indicatorReceived() {
1572            return this.binding.indicatorReceived;
1573        }
1574
1575        @Override
1576        protected TextView time() {
1577            return this.binding.messageTime;
1578        }
1579
1580        @Override
1581        protected TextView messageBody() {
1582            return this.binding.messageContent.messageBody;
1583        }
1584
1585        protected TextView encryption() {
1586            return this.binding.messageEncryption;
1587        }
1588
1589        @Override
1590        protected ImageView contactPicture() {
1591            return this.binding.messagePhoto;
1592        }
1593
1594        @Override
1595        protected ChipGroup reactions() {
1596            return this.binding.reactions;
1597        }
1598    }
1599
1600    private static class EndBubbleMessageItemViewHolder extends BubbleMessageItemViewHolder {
1601
1602        private final ItemMessageEndBinding binding;
1603
1604        private EndBubbleMessageItemViewHolder(@NonNull ItemMessageEndBinding binding) {
1605            super(binding.getRoot());
1606            this.binding = binding;
1607        }
1608
1609        @Override
1610        public ConstraintLayout root() {
1611            return (ConstraintLayout) this.binding.getRoot();
1612        }
1613
1614        @Override
1615        protected ImageView indicatorEdit() {
1616            return this.binding.editIndicator;
1617        }
1618
1619        @Override
1620        protected RelativeLayout audioPlayer() {
1621            return this.binding.messageContent.audioPlayer;
1622        }
1623
1624        @Override
1625        protected LinearLayout messageBox() {
1626            return this.binding.messageBox;
1627        }
1628
1629        @Override
1630        protected MaterialButton downloadButton() {
1631            return this.binding.messageContent.downloadButton;
1632        }
1633
1634        @Override
1635        protected ImageView image() {
1636            return this.binding.messageContent.messageImage;
1637        }
1638
1639        @Override
1640        protected ImageView indicatorSecurity() {
1641            return this.binding.securityIndicator;
1642        }
1643
1644        @Override
1645        protected ImageView indicatorReceived() {
1646            return this.binding.indicatorReceived;
1647        }
1648
1649        @Override
1650        protected TextView time() {
1651            return this.binding.messageTime;
1652        }
1653
1654        @Override
1655        protected TextView messageBody() {
1656            return this.binding.messageContent.messageBody;
1657        }
1658
1659        @Override
1660        protected ImageView contactPicture() {
1661            return this.binding.messagePhoto;
1662        }
1663
1664        @Override
1665        protected ChipGroup reactions() {
1666            return this.binding.reactions;
1667        }
1668    }
1669
1670    private static class DateSeperatorMessageItemViewHolder extends MessageItemViewHolder {
1671
1672        private final ItemMessageDateBubbleBinding binding;
1673
1674        private DateSeperatorMessageItemViewHolder(@NonNull ItemMessageDateBubbleBinding binding) {
1675            super(binding.getRoot());
1676            this.binding = binding;
1677        }
1678    }
1679
1680    private static class RtpSessionMessageItemViewHolder extends MessageItemViewHolder {
1681
1682        private final ItemMessageRtpSessionBinding binding;
1683
1684        private RtpSessionMessageItemViewHolder(@NonNull ItemMessageRtpSessionBinding binding) {
1685            super(binding.getRoot());
1686            this.binding = binding;
1687        }
1688    }
1689
1690    private static class StatusMessageItemViewHolder extends MessageItemViewHolder {
1691
1692        private final ItemMessageStatusBinding binding;
1693
1694        private StatusMessageItemViewHolder(@NonNull ItemMessageStatusBinding binding) {
1695            super(binding.getRoot());
1696            this.binding = binding;
1697        }
1698    }
1699}