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