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.util.Log;
  19import android.view.View;
  20import android.view.ViewGroup;
  21import android.view.WindowManager;
  22import android.widget.ArrayAdapter;
  23import android.widget.Button;
  24import android.widget.ImageView;
  25import android.widget.LinearLayout;
  26import android.widget.RelativeLayout;
  27import android.widget.TextView;
  28import android.widget.Toast;
  29
  30import androidx.annotation.AttrRes;
  31import androidx.annotation.ColorInt;
  32import androidx.annotation.DrawableRes;
  33import androidx.annotation.NonNull;
  34import androidx.annotation.Nullable;
  35import androidx.core.app.ActivityCompat;
  36import androidx.core.content.ContextCompat;
  37import androidx.core.widget.ImageViewCompat;
  38import androidx.databinding.DataBindingUtil;
  39import androidx.emoji2.emojipicker.EmojiViewItem;
  40import androidx.emoji2.emojipicker.RecentEmojiProvider;
  41
  42import com.google.android.material.button.MaterialButton;
  43import com.google.android.material.chip.ChipGroup;
  44import com.google.android.material.color.MaterialColors;
  45import com.google.android.material.dialog.MaterialAlertDialogBuilder;
  46import com.google.common.base.Joiner;
  47import com.google.common.base.Strings;
  48import com.google.common.collect.Collections2;
  49import com.google.common.collect.ImmutableList;
  50import com.google.common.collect.ImmutableSet;
  51import com.google.common.collect.Lists;
  52
  53import eu.siacs.conversations.AppSettings;
  54import eu.siacs.conversations.Config;
  55import eu.siacs.conversations.R;
  56import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
  57import eu.siacs.conversations.databinding.DialogAddReactionBinding;
  58import eu.siacs.conversations.entities.Account;
  59import eu.siacs.conversations.entities.Conversation;
  60import eu.siacs.conversations.entities.Conversational;
  61import eu.siacs.conversations.entities.DownloadableFile;
  62import eu.siacs.conversations.entities.Message;
  63import eu.siacs.conversations.entities.Message.FileParams;
  64import eu.siacs.conversations.entities.RtpSessionStatus;
  65import eu.siacs.conversations.entities.Transferable;
  66import eu.siacs.conversations.persistance.FileBackend;
  67import eu.siacs.conversations.services.MessageArchiveService;
  68import eu.siacs.conversations.services.NotificationService;
  69import eu.siacs.conversations.ui.Activities;
  70import eu.siacs.conversations.ui.BindingAdapters;
  71import eu.siacs.conversations.ui.ConversationFragment;
  72import eu.siacs.conversations.ui.ConversationsActivity;
  73import eu.siacs.conversations.ui.XmppActivity;
  74import eu.siacs.conversations.ui.service.AudioPlayer;
  75import eu.siacs.conversations.ui.text.DividerSpan;
  76import eu.siacs.conversations.ui.text.QuoteSpan;
  77import eu.siacs.conversations.ui.util.Attachment;
  78import eu.siacs.conversations.ui.util.AvatarWorkerTask;
  79import eu.siacs.conversations.ui.util.MyLinkify;
  80import eu.siacs.conversations.ui.util.QuoteHelper;
  81import eu.siacs.conversations.ui.util.ViewUtil;
  82import eu.siacs.conversations.ui.widget.ClickableMovementMethod;
  83import eu.siacs.conversations.utils.CryptoHelper;
  84import eu.siacs.conversations.utils.Emoticons;
  85import eu.siacs.conversations.utils.GeoHelper;
  86import eu.siacs.conversations.utils.MessageUtils;
  87import eu.siacs.conversations.utils.StylingHelper;
  88import eu.siacs.conversations.utils.TimeFrameUtils;
  89import eu.siacs.conversations.utils.UIHelper;
  90import eu.siacs.conversations.xmpp.Jid;
  91import eu.siacs.conversations.xmpp.mam.MamReference;
  92import kotlin.coroutines.Continuation;
  93
  94import java.net.URI;
  95import java.util.Arrays;
  96import java.util.Collection;
  97import java.util.List;
  98import java.util.Locale;
  99import java.util.function.Consumer;
 100import java.util.regex.Matcher;
 101import java.util.regex.Pattern;
 102
 103public class MessageAdapter extends ArrayAdapter<Message> {
 104
 105    public static final String DATE_SEPARATOR_BODY = "DATE_SEPARATOR";
 106    private static final int SENT = 0;
 107    private static final int RECEIVED = 1;
 108    private static final int STATUS = 2;
 109    private static final int DATE_SEPARATOR = 3;
 110    private static final int RTP_SESSION = 4;
 111    private final XmppActivity activity;
 112    private final AudioPlayer audioPlayer;
 113    private List<String> highlightedTerm = null;
 114    private final DisplayMetrics metrics;
 115    private OnContactPictureClicked mOnContactPictureClickedListener;
 116    private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
 117    private BubbleDesign bubbleDesign = new BubbleDesign(false, false);
 118    private final boolean mForceNames;
 119
 120    public MessageAdapter(
 121            final XmppActivity activity, final List<Message> messages, final boolean forceNames) {
 122        super(activity, 0, messages);
 123        this.audioPlayer = new AudioPlayer(this);
 124        this.activity = activity;
 125        metrics = getContext().getResources().getDisplayMetrics();
 126        updatePreferences();
 127        this.mForceNames = forceNames;
 128    }
 129
 130    public MessageAdapter(final XmppActivity activity, final List<Message> messages) {
 131        this(activity, messages, false);
 132    }
 133
 134    private static void resetClickListener(View... views) {
 135        for (View view : views) {
 136            view.setOnClickListener(null);
 137        }
 138    }
 139
 140    public void flagScreenOn() {
 141        activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
 142    }
 143
 144    public void flagScreenOff() {
 145        activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
 146    }
 147
 148    public void setVolumeControl(final int stream) {
 149        activity.setVolumeControlStream(stream);
 150    }
 151
 152    public void setOnContactPictureClicked(OnContactPictureClicked listener) {
 153        this.mOnContactPictureClickedListener = listener;
 154    }
 155
 156    public Activity getActivity() {
 157        return activity;
 158    }
 159
 160    public void setOnContactPictureLongClicked(OnContactPictureLongClicked listener) {
 161        this.mOnContactPictureLongClickedListener = listener;
 162    }
 163
 164    @Override
 165    public int getViewTypeCount() {
 166        return 5;
 167    }
 168
 169    private int getItemViewType(Message message) {
 170        if (message.getType() == Message.TYPE_STATUS) {
 171            if (DATE_SEPARATOR_BODY.equals(message.getBody())) {
 172                return DATE_SEPARATOR;
 173            } else {
 174                return STATUS;
 175            }
 176        } else if (message.getType() == Message.TYPE_RTP_SESSION) {
 177            return RTP_SESSION;
 178        } else if (message.getStatus() <= Message.STATUS_RECEIVED) {
 179            return RECEIVED;
 180        } else {
 181            return SENT;
 182        }
 183    }
 184
 185    @Override
 186    public int getItemViewType(int position) {
 187        return this.getItemViewType(getItem(position));
 188    }
 189
 190    private void displayStatus(
 191            final ViewHolder viewHolder,
 192            final Message message,
 193            final int type,
 194            final BubbleColor bubbleColor) {
 195        final int mergedStatus = message.getMergedStatus();
 196        final boolean error;
 197        if (viewHolder.indicatorReceived != null) {
 198            viewHolder.indicatorReceived.setVisibility(View.GONE);
 199        }
 200        final Transferable transferable = message.getTransferable();
 201        final boolean multiReceived =
 202                message.getConversation().getMode() == Conversation.MODE_MULTI
 203                        && mergedStatus <= Message.STATUS_RECEIVED;
 204        final String fileSize;
 205        if (message.isFileOrImage()
 206                || transferable != null
 207                || MessageUtils.unInitiatedButKnownSize(message)) {
 208            final FileParams params = message.getFileParams();
 209            fileSize = params.size != null ? UIHelper.filesizeToString(params.size) : null;
 210            if (message.getStatus() == Message.STATUS_SEND_FAILED
 211                    || (transferable != null
 212                            && (transferable.getStatus() == Transferable.STATUS_FAILED
 213                                    || transferable.getStatus()
 214                                            == Transferable.STATUS_CANCELLED))) {
 215                error = true;
 216            } else {
 217                error = message.getStatus() == Message.STATUS_SEND_FAILED;
 218            }
 219        } else {
 220            fileSize = null;
 221            error = message.getStatus() == Message.STATUS_SEND_FAILED;
 222        }
 223        if (type == SENT) {
 224            final @DrawableRes Integer receivedIndicator =
 225                    getMessageStatusAsDrawable(message, mergedStatus);
 226            if (receivedIndicator == null) {
 227                viewHolder.indicatorReceived.setVisibility(View.INVISIBLE);
 228            } else {
 229                viewHolder.indicatorReceived.setImageResource(receivedIndicator);
 230                if (mergedStatus == Message.STATUS_SEND_FAILED) {
 231                    setImageTintError(viewHolder.indicatorReceived);
 232                } else {
 233                    setImageTint(viewHolder.indicatorReceived, bubbleColor);
 234                }
 235                viewHolder.indicatorReceived.setVisibility(View.VISIBLE);
 236            }
 237        }
 238        final var additionalStatusInfo = getAdditionalStatusInfo(message, mergedStatus);
 239
 240        if (error && type == SENT) {
 241            viewHolder.time.setTextColor(
 242                    MaterialColors.getColor(
 243                            viewHolder.time, com.google.android.material.R.attr.colorError));
 244        } else {
 245            setTextColor(viewHolder.time, bubbleColor);
 246        }
 247        if (message.getEncryption() == Message.ENCRYPTION_NONE) {
 248            viewHolder.indicator.setVisibility(View.GONE);
 249        } else {
 250            boolean verified = false;
 251            if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
 252                final FingerprintStatus status =
 253                        message.getConversation()
 254                                .getAccount()
 255                                .getAxolotlService()
 256                                .getFingerprintTrust(message.getFingerprint());
 257                if (status != null && status.isVerified()) {
 258                    verified = true;
 259                }
 260            }
 261            if (verified) {
 262                viewHolder.indicator.setImageResource(R.drawable.ic_verified_user_24dp);
 263            } else {
 264                viewHolder.indicator.setImageResource(R.drawable.ic_lock_24dp);
 265            }
 266            if (error && type == SENT) {
 267                setImageTintError(viewHolder.indicator);
 268            } else {
 269                setImageTint(viewHolder.indicator, bubbleColor);
 270            }
 271            viewHolder.indicator.setVisibility(View.VISIBLE);
 272        }
 273
 274        if (viewHolder.edit_indicator != null) {
 275            if (message.edited()) {
 276                viewHolder.edit_indicator.setVisibility(View.VISIBLE);
 277                if (error && type == SENT) {
 278                    setImageTintError(viewHolder.edit_indicator);
 279                } else {
 280                    setImageTint(viewHolder.edit_indicator, bubbleColor);
 281                }
 282            } else {
 283                viewHolder.edit_indicator.setVisibility(View.GONE);
 284            }
 285        }
 286
 287        final String formattedTime =
 288                UIHelper.readableTimeDifferenceFull(getContext(), message.getMergedTimeSent());
 289        final String bodyLanguage = message.getBodyLanguage();
 290        final ImmutableList.Builder<String> timeInfoBuilder = new ImmutableList.Builder<>();
 291        if (message.getStatus() <= Message.STATUS_RECEIVED) {
 292            timeInfoBuilder.add(formattedTime);
 293            if (fileSize != null) {
 294                timeInfoBuilder.add(fileSize);
 295            }
 296            if (mForceNames || multiReceived) {
 297                final String displayName = UIHelper.getMessageDisplayName(message);
 298                if (displayName != null) {
 299                    timeInfoBuilder.add(displayName);
 300                }
 301            }
 302            if (bodyLanguage != null) {
 303                timeInfoBuilder.add(bodyLanguage.toUpperCase(Locale.US));
 304            }
 305        } else {
 306            if (bodyLanguage != null) {
 307                timeInfoBuilder.add(bodyLanguage.toUpperCase(Locale.US));
 308            }
 309            if (fileSize != null) {
 310                timeInfoBuilder.add(fileSize);
 311            }
 312            // for space reasons we display only 'additional status info' (send progress or concrete
 313            // failure reason) or the time
 314            if (additionalStatusInfo != null) {
 315                timeInfoBuilder.add(additionalStatusInfo);
 316            } else {
 317                timeInfoBuilder.add(formattedTime);
 318            }
 319        }
 320        final var timeInfo = timeInfoBuilder.build();
 321        viewHolder.time.setText(Joiner.on(" \u00B7 ").join(timeInfo));
 322    }
 323
 324    public static @DrawableRes Integer getMessageStatusAsDrawable(
 325            final Message message, final int status) {
 326        final var transferable = message.getTransferable();
 327        return switch (status) {
 328            case Message.STATUS_WAITING -> R.drawable.ic_more_horiz_24dp;
 329            case Message.STATUS_UNSEND -> transferable == null ? null : R.drawable.ic_upload_24dp;
 330            case Message.STATUS_SEND -> R.drawable.ic_done_24dp;
 331            case Message.STATUS_SEND_RECEIVED, Message.STATUS_SEND_DISPLAYED -> R.drawable
 332                    .ic_done_all_24dp;
 333            case Message.STATUS_SEND_FAILED -> {
 334                final String errorMessage = message.getErrorMessage();
 335                if (Message.ERROR_MESSAGE_CANCELLED.equals(errorMessage)) {
 336                    yield R.drawable.ic_cancel_24dp;
 337                } else {
 338                    yield R.drawable.ic_error_24dp;
 339                }
 340            }
 341            case Message.STATUS_OFFERED -> R.drawable.ic_p2p_24dp;
 342            default -> null;
 343        };
 344    }
 345
 346    @Nullable
 347    private String getAdditionalStatusInfo(final Message message, final int mergedStatus) {
 348        final String additionalStatusInfo;
 349        if (mergedStatus == Message.STATUS_SEND_FAILED) {
 350            final String errorMessage = Strings.nullToEmpty(message.getErrorMessage());
 351            final String[] errorParts = errorMessage.split("\\u001f", 2);
 352            if (errorParts.length == 2 && errorParts[0].equals("file-too-large")) {
 353                additionalStatusInfo = getContext().getString(R.string.file_too_large);
 354            } else {
 355                additionalStatusInfo = null;
 356            }
 357        } else if (mergedStatus == Message.STATUS_UNSEND) {
 358            final var transferable = message.getTransferable();
 359            if (transferable == null) {
 360                return null;
 361            }
 362            return getContext().getString(R.string.sending_file, transferable.getProgress());
 363        } else {
 364            additionalStatusInfo = null;
 365        }
 366        return additionalStatusInfo;
 367    }
 368
 369    private void displayInfoMessage(
 370            ViewHolder viewHolder, CharSequence text, final BubbleColor bubbleColor) {
 371        viewHolder.download_button.setVisibility(View.GONE);
 372        viewHolder.audioPlayer.setVisibility(View.GONE);
 373        viewHolder.image.setVisibility(View.GONE);
 374        viewHolder.messageBody.setVisibility(View.VISIBLE);
 375        viewHolder.messageBody.setText(text);
 376        viewHolder.messageBody.setTextColor(
 377                bubbleToOnSurfaceVariant(viewHolder.messageBody, bubbleColor));
 378        viewHolder.messageBody.setTextIsSelectable(false);
 379    }
 380
 381    private void displayEmojiMessage(
 382            final ViewHolder viewHolder, final String body, final BubbleColor bubbleColor) {
 383        viewHolder.download_button.setVisibility(View.GONE);
 384        viewHolder.audioPlayer.setVisibility(View.GONE);
 385        viewHolder.image.setVisibility(View.GONE);
 386        viewHolder.messageBody.setVisibility(View.VISIBLE);
 387        setTextColor(viewHolder.messageBody, bubbleColor);
 388        final Spannable span = new SpannableString(body);
 389        float size = Emoticons.isEmoji(body) ? 3.0f : 2.0f;
 390        span.setSpan(
 391                new RelativeSizeSpan(size), 0, body.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 392        viewHolder.messageBody.setText(span);
 393    }
 394
 395    private void applyQuoteSpan(
 396            final TextView textView,
 397            SpannableStringBuilder body,
 398            int start,
 399            int end,
 400            final BubbleColor bubbleColor) {
 401        if (start > 1 && !"\n\n".equals(body.subSequence(start - 2, start).toString())) {
 402            body.insert(start++, "\n");
 403            body.setSpan(
 404                    new DividerSpan(false), start - 2, start, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 405            end++;
 406        }
 407        if (end < body.length() - 1 && !"\n\n".equals(body.subSequence(end, end + 2).toString())) {
 408            body.insert(end, "\n");
 409            body.setSpan(new DividerSpan(false), end, end + 2, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 410        }
 411        final DisplayMetrics metrics = getContext().getResources().getDisplayMetrics();
 412        body.setSpan(
 413                new QuoteSpan(bubbleToOnSurfaceVariant(textView, bubbleColor), metrics),
 414                start,
 415                end,
 416                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 417    }
 418
 419    /**
 420     * Applies QuoteSpan to group of lines which starts with > or » characters. Appends likebreaks
 421     * and applies DividerSpan to them to show a padding between quote and text.
 422     */
 423    private boolean handleTextQuotes(
 424            final TextView textView,
 425            final SpannableStringBuilder body,
 426            final BubbleColor bubbleColor) {
 427        boolean startsWithQuote = false;
 428        int quoteDepth = 1;
 429        while (QuoteHelper.bodyContainsQuoteStart(body) && quoteDepth <= Config.QUOTE_MAX_DEPTH) {
 430            char previous = '\n';
 431            int lineStart = -1;
 432            int lineTextStart = -1;
 433            int quoteStart = -1;
 434            for (int i = 0; i <= body.length(); i++) {
 435                char current = body.length() > i ? body.charAt(i) : '\n';
 436                if (lineStart == -1) {
 437                    if (previous == '\n') {
 438                        if (i < body.length() && QuoteHelper.isPositionQuoteStart(body, i)) {
 439                            // Line start with quote
 440                            lineStart = i;
 441                            if (quoteStart == -1) quoteStart = i;
 442                            if (i == 0) startsWithQuote = true;
 443                        } else if (quoteStart >= 0) {
 444                            // Line start without quote, apply spans there
 445                            applyQuoteSpan(textView, body, quoteStart, i - 1, bubbleColor);
 446                            quoteStart = -1;
 447                        }
 448                    }
 449                } else {
 450                    // Remove extra spaces between > and first character in the line
 451                    // > character will be removed too
 452                    if (current != ' ' && lineTextStart == -1) {
 453                        lineTextStart = i;
 454                    }
 455                    if (current == '\n') {
 456                        body.delete(lineStart, lineTextStart);
 457                        i -= lineTextStart - lineStart;
 458                        if (i == lineStart) {
 459                            // Avoid empty lines because span over empty line can be hidden
 460                            body.insert(i++, " ");
 461                        }
 462                        lineStart = -1;
 463                        lineTextStart = -1;
 464                    }
 465                }
 466                previous = current;
 467            }
 468            if (quoteStart >= 0) {
 469                // Apply spans to finishing open quote
 470                applyQuoteSpan(textView, body, quoteStart, body.length(), bubbleColor);
 471            }
 472            quoteDepth++;
 473        }
 474        return startsWithQuote;
 475    }
 476
 477    private void displayTextMessage(
 478            final ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor) {
 479        viewHolder.download_button.setVisibility(View.GONE);
 480        viewHolder.image.setVisibility(View.GONE);
 481        viewHolder.audioPlayer.setVisibility(View.GONE);
 482        viewHolder.messageBody.setVisibility(View.VISIBLE);
 483        setTextColor(viewHolder.messageBody, bubbleColor);
 484        setTextSize(viewHolder.messageBody, this.bubbleDesign.largeFont);
 485        viewHolder.messageBody.setTypeface(null, Typeface.NORMAL);
 486
 487        if (message.getBody() != null) {
 488            final String nick = UIHelper.getMessageDisplayName(message);
 489            SpannableStringBuilder body = message.getMergedBody();
 490            boolean hasMeCommand = message.hasMeCommand();
 491            if (hasMeCommand) {
 492                body = body.replace(0, Message.ME_COMMAND.length(), nick + " ");
 493            }
 494            if (body.length() > Config.MAX_DISPLAY_MESSAGE_CHARS) {
 495                body = new SpannableStringBuilder(body, 0, Config.MAX_DISPLAY_MESSAGE_CHARS);
 496                body.append("\u2026");
 497            }
 498            Message.MergeSeparator[] mergeSeparators =
 499                    body.getSpans(0, body.length(), Message.MergeSeparator.class);
 500            for (Message.MergeSeparator mergeSeparator : mergeSeparators) {
 501                int start = body.getSpanStart(mergeSeparator);
 502                int end = body.getSpanEnd(mergeSeparator);
 503                body.setSpan(new DividerSpan(true), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 504            }
 505            boolean startsWithQuote = handleTextQuotes(viewHolder.messageBody, body, bubbleColor);
 506            if (!message.isPrivateMessage()) {
 507                if (hasMeCommand) {
 508                    body.setSpan(
 509                            new StyleSpan(Typeface.BOLD_ITALIC),
 510                            0,
 511                            nick.length(),
 512                            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 513                }
 514            } else {
 515                String privateMarker;
 516                if (message.getStatus() <= Message.STATUS_RECEIVED) {
 517                    privateMarker = activity.getString(R.string.private_message);
 518                } else {
 519                    Jid cp = message.getCounterpart();
 520                    privateMarker =
 521                            activity.getString(
 522                                    R.string.private_message_to,
 523                                    Strings.nullToEmpty(cp == null ? null : cp.getResource()));
 524                }
 525                body.insert(0, privateMarker);
 526                int privateMarkerIndex = privateMarker.length();
 527                if (startsWithQuote) {
 528                    body.insert(privateMarkerIndex, "\n\n");
 529                    body.setSpan(
 530                            new DividerSpan(false),
 531                            privateMarkerIndex,
 532                            privateMarkerIndex + 2,
 533                            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 534                } else {
 535                    body.insert(privateMarkerIndex, " ");
 536                }
 537                body.setSpan(
 538                        new ForegroundColorSpan(
 539                                bubbleToOnSurfaceVariant(viewHolder.messageBody, bubbleColor)),
 540                        0,
 541                        privateMarkerIndex,
 542                        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 543                body.setSpan(
 544                        new StyleSpan(Typeface.BOLD),
 545                        0,
 546                        privateMarkerIndex,
 547                        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 548                if (hasMeCommand) {
 549                    body.setSpan(
 550                            new StyleSpan(Typeface.BOLD_ITALIC),
 551                            privateMarkerIndex + 1,
 552                            privateMarkerIndex + 1 + nick.length(),
 553                            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 554                }
 555            }
 556            if (message.getConversation().getMode() == Conversation.MODE_MULTI
 557                    && message.getStatus() == Message.STATUS_RECEIVED) {
 558                if (message.getConversation() instanceof Conversation conversation) {
 559                    Pattern pattern =
 560                            NotificationService.generateNickHighlightPattern(
 561                                    conversation.getMucOptions().getActualNick());
 562                    Matcher matcher = pattern.matcher(body);
 563                    while (matcher.find()) {
 564                        body.setSpan(
 565                                new StyleSpan(Typeface.BOLD),
 566                                matcher.start(),
 567                                matcher.end(),
 568                                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 569                    }
 570                }
 571            }
 572            Matcher matcher = Emoticons.getEmojiPattern(body).matcher(body);
 573            while (matcher.find()) {
 574                if (matcher.start() < matcher.end()) {
 575                    body.setSpan(
 576                            new RelativeSizeSpan(1.2f),
 577                            matcher.start(),
 578                            matcher.end(),
 579                            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 580                }
 581            }
 582
 583            StylingHelper.format(body, viewHolder.messageBody.getCurrentTextColor());
 584            MyLinkify.addLinks(body, true);
 585            if (highlightedTerm != null) {
 586                StylingHelper.highlight(viewHolder.messageBody, body, highlightedTerm);
 587            }
 588            viewHolder.messageBody.setAutoLinkMask(0);
 589            viewHolder.messageBody.setText(body);
 590            viewHolder.messageBody.setMovementMethod(ClickableMovementMethod.getInstance());
 591        } else {
 592            viewHolder.messageBody.setText("");
 593            viewHolder.messageBody.setTextIsSelectable(false);
 594        }
 595    }
 596
 597    private void displayDownloadableMessage(
 598            ViewHolder viewHolder,
 599            final Message message,
 600            String text,
 601            final BubbleColor bubbleColor) {
 602        toggleWhisperInfo(viewHolder, message, bubbleColor);
 603        viewHolder.image.setVisibility(View.GONE);
 604        viewHolder.audioPlayer.setVisibility(View.GONE);
 605        viewHolder.download_button.setVisibility(View.VISIBLE);
 606        viewHolder.download_button.setText(text);
 607        final var attachment = Attachment.of(message);
 608        final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
 609        viewHolder.download_button.setIconResource(imageResource);
 610        viewHolder.download_button.setOnClickListener(
 611                v -> ConversationFragment.downloadFile(activity, message));
 612    }
 613
 614    private void displayOpenableMessage(
 615            ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor) {
 616        toggleWhisperInfo(viewHolder, message, bubbleColor);
 617        viewHolder.image.setVisibility(View.GONE);
 618        viewHolder.audioPlayer.setVisibility(View.GONE);
 619        viewHolder.download_button.setVisibility(View.VISIBLE);
 620        viewHolder.download_button.setText(
 621                activity.getString(
 622                        R.string.open_x_file,
 623                        UIHelper.getFileDescriptionString(activity, message)));
 624        final var attachment = Attachment.of(message);
 625        final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
 626        viewHolder.download_button.setIconResource(imageResource);
 627        viewHolder.download_button.setOnClickListener(v -> openDownloadable(message));
 628    }
 629
 630    private void displayLocationMessage(
 631            ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor) {
 632        toggleWhisperInfo(viewHolder, message, bubbleColor);
 633        viewHolder.image.setVisibility(View.GONE);
 634        viewHolder.audioPlayer.setVisibility(View.GONE);
 635        viewHolder.download_button.setVisibility(View.VISIBLE);
 636        viewHolder.download_button.setText(R.string.show_location);
 637        final var attachment = Attachment.of(message);
 638        final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
 639        viewHolder.download_button.setIconResource(imageResource);
 640        viewHolder.download_button.setOnClickListener(v -> showLocation(message));
 641    }
 642
 643    private void displayAudioMessage(
 644            ViewHolder viewHolder, Message message, final BubbleColor bubbleColor) {
 645        toggleWhisperInfo(viewHolder, message, bubbleColor);
 646        viewHolder.image.setVisibility(View.GONE);
 647        viewHolder.download_button.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            ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor) {
 656        toggleWhisperInfo(viewHolder, message, bubbleColor);
 657        viewHolder.download_button.setVisibility(View.GONE);
 658        viewHolder.audioPlayer.setVisibility(View.GONE);
 659        viewHolder.image.setVisibility(View.VISIBLE);
 660        final FileParams params = message.getFileParams();
 661        final float target = activity.getResources().getDimension(R.dimen.image_preview_width);
 662        final int scaledW;
 663        final int scaledH;
 664        if (Math.max(params.height, params.width) * metrics.density <= target) {
 665            scaledW = (int) (params.width * metrics.density);
 666            scaledH = (int) (params.height * metrics.density);
 667        } else if (Math.max(params.height, params.width) <= target) {
 668            scaledW = params.width;
 669            scaledH = params.height;
 670        } else if (params.width <= params.height) {
 671            scaledW = (int) (params.width / ((double) params.height / target));
 672            scaledH = (int) target;
 673        } else {
 674            scaledW = (int) target;
 675            scaledH = (int) (params.height / ((double) params.width / target));
 676        }
 677        final LinearLayout.LayoutParams layoutParams =
 678                new LinearLayout.LayoutParams(scaledW, scaledH);
 679        viewHolder.image.setLayoutParams(layoutParams);
 680        activity.loadBitmap(message, viewHolder.image);
 681        viewHolder.image.setOnClickListener(v -> openDownloadable(message));
 682    }
 683
 684    private void toggleWhisperInfo(
 685            ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor) {
 686        if (message.isPrivateMessage()) {
 687            final String privateMarker;
 688            if (message.getStatus() <= Message.STATUS_RECEIVED) {
 689                privateMarker = activity.getString(R.string.private_message);
 690            } else {
 691                Jid cp = message.getCounterpart();
 692                privateMarker =
 693                        activity.getString(
 694                                R.string.private_message_to,
 695                                Strings.nullToEmpty(cp == null ? null : cp.getResource()));
 696            }
 697            final SpannableString body = new SpannableString(privateMarker);
 698            body.setSpan(
 699                    new ForegroundColorSpan(
 700                            bubbleToOnSurfaceVariant(viewHolder.messageBody, bubbleColor)),
 701                    0,
 702                    privateMarker.length(),
 703                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 704            body.setSpan(
 705                    new StyleSpan(Typeface.BOLD),
 706                    0,
 707                    privateMarker.length(),
 708                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 709            viewHolder.messageBody.setText(body);
 710            viewHolder.messageBody.setVisibility(View.VISIBLE);
 711        } else {
 712            viewHolder.messageBody.setVisibility(View.GONE);
 713        }
 714    }
 715
 716    private void loadMoreMessages(Conversation conversation) {
 717        conversation.setLastClearHistory(0, null);
 718        activity.xmppConnectionService.updateConversation(conversation);
 719        conversation.setHasMessagesLeftOnServer(true);
 720        conversation.setFirstMamReference(null);
 721        long timestamp = conversation.getLastMessageTransmitted().getTimestamp();
 722        if (timestamp == 0) {
 723            timestamp = System.currentTimeMillis();
 724        }
 725        conversation.messagesLoaded.set(true);
 726        MessageArchiveService.Query query =
 727                activity.xmppConnectionService
 728                        .getMessageArchiveService()
 729                        .query(conversation, new MamReference(0), timestamp, false);
 730        if (query != null) {
 731            Toast.makeText(activity, R.string.fetching_history_from_server, Toast.LENGTH_LONG)
 732                    .show();
 733        } else {
 734            Toast.makeText(
 735                            activity,
 736                            R.string.not_fetching_history_retention_period,
 737                            Toast.LENGTH_SHORT)
 738                    .show();
 739        }
 740    }
 741
 742    @Override
 743    public View getView(final int position, View view, final @NonNull ViewGroup parent) {
 744        final Message message = getItem(position);
 745        final boolean omemoEncryption = message.getEncryption() == Message.ENCRYPTION_AXOLOTL;
 746        final boolean isInValidSession =
 747                message.isValidInSession() && (!omemoEncryption || message.isTrusted());
 748        final Conversational conversation = message.getConversation();
 749        final Account account = conversation.getAccount();
 750        final int type = getItemViewType(position);
 751        ViewHolder viewHolder;
 752        if (view == null) {
 753            viewHolder = new ViewHolder();
 754            switch (type) {
 755                case DATE_SEPARATOR:
 756                    view =
 757                            activity.getLayoutInflater()
 758                                    .inflate(R.layout.item_message_date_bubble, parent, false);
 759                    viewHolder.status_message = view.findViewById(R.id.message_body);
 760                    viewHolder.message_box = view.findViewById(R.id.message_box);
 761                    break;
 762                case RTP_SESSION:
 763                    view =
 764                            activity.getLayoutInflater()
 765                                    .inflate(R.layout.item_message_rtp_session, parent, false);
 766                    viewHolder.status_message = view.findViewById(R.id.message_body);
 767                    viewHolder.message_box = view.findViewById(R.id.message_box);
 768                    viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
 769                    break;
 770                case SENT:
 771                    view =
 772                            activity.getLayoutInflater()
 773                                    .inflate(R.layout.item_message_sent, parent, false);
 774                    viewHolder.message_box = view.findViewById(R.id.message_box);
 775                    viewHolder.contact_picture = view.findViewById(R.id.message_photo);
 776                    viewHolder.download_button = view.findViewById(R.id.download_button);
 777                    viewHolder.indicator = view.findViewById(R.id.security_indicator);
 778                    viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
 779                    viewHolder.image = view.findViewById(R.id.message_image);
 780                    viewHolder.messageBody = view.findViewById(R.id.message_body);
 781                    viewHolder.time = view.findViewById(R.id.message_time);
 782                    viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
 783                    viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
 784                    viewHolder.reactions = view.findViewById(R.id.reactions);
 785                    break;
 786                case RECEIVED:
 787                    view =
 788                            activity.getLayoutInflater()
 789                                    .inflate(R.layout.item_message_received, parent, false);
 790                    viewHolder.message_box = view.findViewById(R.id.message_box);
 791                    viewHolder.contact_picture = view.findViewById(R.id.message_photo);
 792                    viewHolder.download_button = view.findViewById(R.id.download_button);
 793                    viewHolder.indicator = view.findViewById(R.id.security_indicator);
 794                    viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
 795                    viewHolder.image = view.findViewById(R.id.message_image);
 796                    viewHolder.messageBody = view.findViewById(R.id.message_body);
 797                    viewHolder.time = view.findViewById(R.id.message_time);
 798                    viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
 799                    viewHolder.encryption = view.findViewById(R.id.message_encryption);
 800                    viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
 801                    viewHolder.reactions = view.findViewById(R.id.reactions);
 802                    break;
 803                case STATUS:
 804                    view =
 805                            activity.getLayoutInflater()
 806                                    .inflate(R.layout.item_message_status, parent, false);
 807                    viewHolder.contact_picture = view.findViewById(R.id.message_photo);
 808                    viewHolder.status_message = view.findViewById(R.id.status_message);
 809                    viewHolder.load_more_messages = view.findViewById(R.id.load_more_messages);
 810                    break;
 811                default:
 812                    throw new AssertionError("Unknown view type");
 813            }
 814            view.setTag(viewHolder);
 815        } else {
 816            viewHolder = (ViewHolder) view.getTag();
 817            if (viewHolder == null) {
 818                return view;
 819            }
 820        }
 821
 822        final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
 823        final BubbleColor bubbleColor;
 824        if (type == RECEIVED) {
 825            if (isInValidSession) {
 826                bubbleColor = colorfulBackground ? BubbleColor.SECONDARY : BubbleColor.SURFACE;
 827            } else {
 828                bubbleColor = BubbleColor.WARNING;
 829            }
 830        } else {
 831            bubbleColor = colorfulBackground ? BubbleColor.TERTIARY : BubbleColor.SURFACE_HIGH;
 832        }
 833
 834        if (type == DATE_SEPARATOR) {
 835            if (UIHelper.today(message.getTimeSent())) {
 836                viewHolder.status_message.setText(R.string.today);
 837            } else if (UIHelper.yesterday(message.getTimeSent())) {
 838                viewHolder.status_message.setText(R.string.yesterday);
 839            } else {
 840                viewHolder.status_message.setText(
 841                        DateUtils.formatDateTime(
 842                                activity,
 843                                message.getTimeSent(),
 844                                DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR));
 845            }
 846            if (colorfulBackground) {
 847                setBackgroundTint(viewHolder.message_box, BubbleColor.PRIMARY);
 848                setTextColor(viewHolder.status_message, BubbleColor.PRIMARY);
 849            } else {
 850                setBackgroundTint(viewHolder.message_box, BubbleColor.SURFACE_HIGH);
 851                setTextColor(viewHolder.status_message, BubbleColor.SURFACE_HIGH);
 852            }
 853            return view;
 854        } else if (type == RTP_SESSION) {
 855            final boolean received = message.getStatus() <= Message.STATUS_RECEIVED;
 856            final RtpSessionStatus rtpSessionStatus = RtpSessionStatus.of(message.getBody());
 857            final long duration = rtpSessionStatus.duration;
 858            if (received) {
 859                if (duration > 0) {
 860                    viewHolder.status_message.setText(
 861                            activity.getString(
 862                                    R.string.incoming_call_duration_timestamp,
 863                                    TimeFrameUtils.resolve(activity, duration),
 864                                    UIHelper.readableTimeDifferenceFull(
 865                                            activity, message.getTimeSent())));
 866                } else if (rtpSessionStatus.successful) {
 867                    viewHolder.status_message.setText(R.string.incoming_call);
 868                } else {
 869                    viewHolder.status_message.setText(
 870                            activity.getString(
 871                                    R.string.missed_call_timestamp,
 872                                    UIHelper.readableTimeDifferenceFull(
 873                                            activity, message.getTimeSent())));
 874                }
 875            } else {
 876                if (duration > 0) {
 877                    viewHolder.status_message.setText(
 878                            activity.getString(
 879                                    R.string.outgoing_call_duration_timestamp,
 880                                    TimeFrameUtils.resolve(activity, duration),
 881                                    UIHelper.readableTimeDifferenceFull(
 882                                            activity, message.getTimeSent())));
 883                } else {
 884                    viewHolder.status_message.setText(
 885                            activity.getString(
 886                                    R.string.outgoing_call_timestamp,
 887                                    UIHelper.readableTimeDifferenceFull(
 888                                            activity, message.getTimeSent())));
 889                }
 890            }
 891            if (colorfulBackground) {
 892                setBackgroundTint(viewHolder.message_box, BubbleColor.SECONDARY);
 893                setTextColor(viewHolder.status_message, BubbleColor.SECONDARY);
 894                setImageTint(viewHolder.indicatorReceived, BubbleColor.SECONDARY);
 895            } else {
 896                setBackgroundTint(viewHolder.message_box, BubbleColor.SURFACE_HIGH);
 897                setTextColor(viewHolder.status_message, BubbleColor.SURFACE_HIGH);
 898                setImageTint(viewHolder.indicatorReceived, BubbleColor.SURFACE_HIGH);
 899            }
 900            viewHolder.indicatorReceived.setImageResource(
 901                    RtpSessionStatus.getDrawable(received, rtpSessionStatus.successful));
 902            return view;
 903        } else if (type == STATUS) {
 904            if ("LOAD_MORE".equals(message.getBody())) {
 905                viewHolder.status_message.setVisibility(View.GONE);
 906                viewHolder.contact_picture.setVisibility(View.GONE);
 907                viewHolder.load_more_messages.setVisibility(View.VISIBLE);
 908                viewHolder.load_more_messages.setOnClickListener(
 909                        v -> loadMoreMessages((Conversation) message.getConversation()));
 910            } else {
 911                viewHolder.status_message.setVisibility(View.VISIBLE);
 912                viewHolder.load_more_messages.setVisibility(View.GONE);
 913                viewHolder.status_message.setText(message.getBody());
 914                boolean showAvatar;
 915                if (conversation.getMode() == Conversation.MODE_SINGLE) {
 916                    showAvatar = true;
 917                    AvatarWorkerTask.loadAvatar(
 918                            message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
 919                } else if (message.getCounterpart() != null
 920                        || message.getTrueCounterpart() != null
 921                        || (message.getCounterparts() != null
 922                                && message.getCounterparts().size() > 0)) {
 923                    showAvatar = true;
 924                    AvatarWorkerTask.loadAvatar(
 925                            message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
 926                } else {
 927                    showAvatar = false;
 928                }
 929                if (showAvatar) {
 930                    viewHolder.contact_picture.setAlpha(0.5f);
 931                    viewHolder.contact_picture.setVisibility(View.VISIBLE);
 932                } else {
 933                    viewHolder.contact_picture.setVisibility(View.GONE);
 934                }
 935            }
 936            return view;
 937        } else {
 938            viewHolder.message_box.setClipToOutline(true);
 939            AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar);
 940        }
 941
 942        resetClickListener(viewHolder.message_box, viewHolder.messageBody);
 943
 944        viewHolder.contact_picture.setOnClickListener(
 945                v -> {
 946                    if (MessageAdapter.this.mOnContactPictureClickedListener != null) {
 947                        MessageAdapter.this.mOnContactPictureClickedListener
 948                                .onContactPictureClicked(message);
 949                    }
 950                });
 951        viewHolder.contact_picture.setOnLongClickListener(
 952                v -> {
 953                    if (MessageAdapter.this.mOnContactPictureLongClickedListener != null) {
 954                        MessageAdapter.this.mOnContactPictureLongClickedListener
 955                                .onContactPictureLongClicked(v, message);
 956                        return true;
 957                    } else {
 958                        return false;
 959                    }
 960                });
 961
 962        final Transferable transferable = message.getTransferable();
 963        final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
 964        if (unInitiatedButKnownSize
 965                || message.isDeleted()
 966                || (transferable != null
 967                        && transferable.getStatus() != Transferable.STATUS_UPLOADING)) {
 968            if (unInitiatedButKnownSize
 969                    || transferable != null
 970                            && transferable.getStatus() == Transferable.STATUS_OFFER) {
 971                displayDownloadableMessage(
 972                        viewHolder,
 973                        message,
 974                        activity.getString(
 975                                R.string.download_x_file,
 976                                UIHelper.getFileDescriptionString(activity, message)),
 977                        bubbleColor);
 978            } else if (transferable != null
 979                    && transferable.getStatus() == Transferable.STATUS_OFFER_CHECK_FILESIZE) {
 980                displayDownloadableMessage(
 981                        viewHolder,
 982                        message,
 983                        activity.getString(
 984                                R.string.check_x_filesize,
 985                                UIHelper.getFileDescriptionString(activity, message)),
 986                        bubbleColor);
 987            } else {
 988                displayInfoMessage(
 989                        viewHolder,
 990                        UIHelper.getMessagePreview(activity, message).first,
 991                        bubbleColor);
 992            }
 993        } else if (message.isFileOrImage()
 994                && message.getEncryption() != Message.ENCRYPTION_PGP
 995                && message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED) {
 996            if (message.getFileParams().width > 0 && message.getFileParams().height > 0) {
 997                displayMediaPreviewMessage(viewHolder, message, bubbleColor);
 998            } else if (message.getFileParams().runtime > 0) {
 999                displayAudioMessage(viewHolder, message, bubbleColor);
1000            } else {
1001                displayOpenableMessage(viewHolder, message, bubbleColor);
1002            }
1003        } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
1004            if (account.isPgpDecryptionServiceConnected()) {
1005                if (conversation instanceof Conversation
1006                        && !account.hasPendingPgpIntent((Conversation) conversation)) {
1007                    displayInfoMessage(
1008                            viewHolder,
1009                            activity.getString(R.string.message_decrypting),
1010                            bubbleColor);
1011                } else {
1012                    displayInfoMessage(
1013                            viewHolder, activity.getString(R.string.pgp_message), bubbleColor);
1014                }
1015            } else {
1016                displayInfoMessage(
1017                        viewHolder, activity.getString(R.string.install_openkeychain), bubbleColor);
1018                viewHolder.message_box.setOnClickListener(this::promptOpenKeychainInstall);
1019                viewHolder.messageBody.setOnClickListener(this::promptOpenKeychainInstall);
1020            }
1021        } else if (message.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) {
1022            displayInfoMessage(
1023                    viewHolder, activity.getString(R.string.decryption_failed), bubbleColor);
1024        } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE) {
1025            displayInfoMessage(
1026                    viewHolder,
1027                    activity.getString(R.string.not_encrypted_for_this_device),
1028                    bubbleColor);
1029        } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_FAILED) {
1030            displayInfoMessage(
1031                    viewHolder, activity.getString(R.string.omemo_decryption_failed), bubbleColor);
1032        } else {
1033            if (message.isGeoUri()) {
1034                displayLocationMessage(viewHolder, message, bubbleColor);
1035            } else if (message.bodyIsOnlyEmojis() && message.getType() != Message.TYPE_PRIVATE) {
1036                displayEmojiMessage(viewHolder, message.getBody().trim(), bubbleColor);
1037            } else if (message.treatAsDownloadable()) {
1038                try {
1039                    final URI uri = new URI(message.getBody());
1040                    displayDownloadableMessage(
1041                            viewHolder,
1042                            message,
1043                            activity.getString(
1044                                    R.string.check_x_filesize_on_host,
1045                                    UIHelper.getFileDescriptionString(activity, message),
1046                                    uri.getHost()),
1047                            bubbleColor);
1048                } catch (Exception e) {
1049                    displayDownloadableMessage(
1050                            viewHolder,
1051                            message,
1052                            activity.getString(
1053                                    R.string.check_x_filesize,
1054                                    UIHelper.getFileDescriptionString(activity, message)),
1055                            bubbleColor);
1056                }
1057            } else {
1058                displayTextMessage(viewHolder, message, bubbleColor);
1059            }
1060        }
1061
1062        setBackgroundTint(viewHolder.message_box, bubbleColor);
1063        setTextColor(viewHolder.messageBody, bubbleColor);
1064
1065        if (type == RECEIVED) {
1066            setTextColor(viewHolder.encryption, bubbleColor);
1067            if (isInValidSession) {
1068                viewHolder.encryption.setVisibility(View.GONE);
1069            } else {
1070                viewHolder.encryption.setVisibility(View.VISIBLE);
1071                if (omemoEncryption && !message.isTrusted()) {
1072                    viewHolder.encryption.setText(R.string.not_trusted);
1073                } else {
1074                    viewHolder.encryption.setText(
1075                            CryptoHelper.encryptionTypeToText(message.getEncryption()));
1076                }
1077            }
1078            BindingAdapters.setReactionsOnReceived(
1079                    viewHolder.reactions,
1080                    message.getAggregatedReactions(),
1081                    reactions -> sendReactions(message, reactions),
1082                    emoji -> showDetailedReaction(message, emoji),
1083                    () -> addReaction(message));
1084        } else if (type == SENT) {
1085            BindingAdapters.setReactionsOnSent(
1086                    viewHolder.reactions,
1087                    message.getAggregatedReactions(),
1088                    reactions -> sendReactions(message, reactions),
1089                    emoji -> showDetailedReaction(message, emoji));
1090        }
1091
1092        displayStatus(viewHolder, message, type, bubbleColor);
1093        return view;
1094    }
1095
1096    private boolean showDetailedReaction(final Message message, final String emoji) {
1097        final var c = message.getConversation();
1098        if (c instanceof Conversation conversation && c.getMode() == Conversational.MODE_MULTI) {
1099            final var reactions =
1100                    Collections2.filter(
1101                            message.getReactions(), r -> r.normalizedReaction().equals(emoji));
1102            final var mucOptions = conversation.getMucOptions();
1103            final var users = mucOptions.findUsers(reactions);
1104            if (users.isEmpty()) {
1105                return true;
1106            }
1107            final MaterialAlertDialogBuilder dialogBuilder =
1108                    new MaterialAlertDialogBuilder(activity);
1109            dialogBuilder.setTitle(emoji);
1110            dialogBuilder.setMessage(UIHelper.concatNames(users));
1111            dialogBuilder.create().show();
1112            return true;
1113        } else {
1114            return false;
1115        }
1116    }
1117
1118    private void sendReactions(final Message message, final Collection<String> reactions) {
1119        if (activity.xmppConnectionService.sendReactions(message, reactions)) {
1120            return;
1121        }
1122        Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
1123    }
1124
1125    private void addReaction(final Message message) {
1126        activity.addReaction(
1127                message,
1128                reactions -> {
1129                    if (activity.xmppConnectionService.sendReactions(message, reactions)) {
1130                        return;
1131                    }
1132                    Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG)
1133                            .show();
1134                });
1135    }
1136
1137    private void promptOpenKeychainInstall(View view) {
1138        activity.showInstallPgpDialog();
1139    }
1140
1141    public FileBackend getFileBackend() {
1142        return activity.xmppConnectionService.getFileBackend();
1143    }
1144
1145    public void stopAudioPlayer() {
1146        audioPlayer.stop();
1147    }
1148
1149    public void unregisterListenerInAudioPlayer() {
1150        audioPlayer.unregisterListener();
1151    }
1152
1153    public void startStopPending() {
1154        audioPlayer.startStopPending();
1155    }
1156
1157    public void openDownloadable(Message message) {
1158        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
1159                && ContextCompat.checkSelfPermission(
1160                                activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)
1161                        != PackageManager.PERMISSION_GRANTED) {
1162            ConversationFragment.registerPendingMessage(activity, message);
1163            ActivityCompat.requestPermissions(
1164                    activity,
1165                    new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE},
1166                    ConversationsActivity.REQUEST_OPEN_MESSAGE);
1167            return;
1168        }
1169        final DownloadableFile file =
1170                activity.xmppConnectionService.getFileBackend().getFile(message);
1171        ViewUtil.view(activity, file);
1172    }
1173
1174    private void showLocation(Message message) {
1175        for (Intent intent : GeoHelper.createGeoIntentsFromMessage(activity, message)) {
1176            if (intent.resolveActivity(getContext().getPackageManager()) != null) {
1177                getContext().startActivity(intent);
1178                return;
1179            }
1180        }
1181        Toast.makeText(
1182                        activity,
1183                        R.string.no_application_found_to_display_location,
1184                        Toast.LENGTH_SHORT)
1185                .show();
1186    }
1187
1188    public void updatePreferences() {
1189        final AppSettings appSettings = new AppSettings(activity);
1190        this.bubbleDesign =
1191                new BubbleDesign(appSettings.isColorfulChatBubbles(), appSettings.isLargeFont());
1192    }
1193
1194    public void setHighlightedTerm(List<String> terms) {
1195        this.highlightedTerm = terms == null ? null : StylingHelper.filterHighlightedWords(terms);
1196    }
1197
1198    public interface OnContactPictureClicked {
1199        void onContactPictureClicked(Message message);
1200    }
1201
1202    public interface OnContactPictureLongClicked {
1203        void onContactPictureLongClicked(View v, Message message);
1204    }
1205
1206    private static void setBackgroundTint(final View view, final BubbleColor bubbleColor) {
1207        view.setBackgroundTintList(bubbleToColorStateList(view, bubbleColor));
1208    }
1209
1210    private static ColorStateList bubbleToColorStateList(
1211            final View view, final BubbleColor bubbleColor) {
1212        final @AttrRes int colorAttributeResId =
1213                switch (bubbleColor) {
1214                    case SURFACE -> Activities.isNightMode(view.getContext())
1215                            ? com.google.android.material.R.attr.colorSurfaceContainerHigh
1216                            : com.google.android.material.R.attr.colorSurfaceContainerLow;
1217                    case SURFACE_HIGH -> Activities.isNightMode(view.getContext())
1218                            ? com.google.android.material.R.attr.colorSurfaceContainerHighest
1219                            : com.google.android.material.R.attr.colorSurfaceContainerHigh;
1220                    case PRIMARY -> com.google.android.material.R.attr.colorPrimaryContainer;
1221                    case SECONDARY -> com.google.android.material.R.attr.colorSecondaryContainer;
1222                    case TERTIARY -> com.google.android.material.R.attr.colorTertiaryContainer;
1223                    case WARNING -> com.google.android.material.R.attr.colorErrorContainer;
1224                };
1225        return ColorStateList.valueOf(MaterialColors.getColor(view, colorAttributeResId));
1226    }
1227
1228    public static void setImageTint(final ImageView imageView, final BubbleColor bubbleColor) {
1229        ImageViewCompat.setImageTintList(
1230                imageView, bubbleToOnSurfaceColorStateList(imageView, bubbleColor));
1231    }
1232
1233    public static void setImageTintError(final ImageView imageView) {
1234        ImageViewCompat.setImageTintList(
1235                imageView,
1236                ColorStateList.valueOf(
1237                        MaterialColors.getColor(
1238                                imageView, com.google.android.material.R.attr.colorError)));
1239    }
1240
1241    public static void setTextColor(final TextView textView, final BubbleColor bubbleColor) {
1242        final var color = bubbleToOnSurfaceColor(textView, bubbleColor);
1243        textView.setTextColor(color);
1244        if (BubbleColor.SURFACES.contains(bubbleColor)) {
1245            textView.setLinkTextColor(
1246                    MaterialColors.getColor(
1247                            textView, com.google.android.material.R.attr.colorPrimary));
1248        } else {
1249            textView.setLinkTextColor(color);
1250        }
1251    }
1252
1253    private static void setTextSize(final TextView textView, final boolean largeFont) {
1254        if (largeFont) {
1255            textView.setTextAppearance(
1256                    com.google.android.material.R.style.TextAppearance_Material3_BodyLarge);
1257        } else {
1258            textView.setTextAppearance(
1259                    com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
1260        }
1261    }
1262
1263    private static @ColorInt int bubbleToOnSurfaceVariant(
1264            final View view, final BubbleColor bubbleColor) {
1265        final @AttrRes int colorAttributeResId;
1266        if (BubbleColor.SURFACES.contains(bubbleColor)) {
1267            colorAttributeResId = com.google.android.material.R.attr.colorOnSurfaceVariant;
1268        } else {
1269            colorAttributeResId = bubbleToOnSurface(bubbleColor);
1270        }
1271        return MaterialColors.getColor(view, colorAttributeResId);
1272    }
1273
1274    private static @ColorInt int bubbleToOnSurfaceColor(
1275            final View view, final BubbleColor bubbleColor) {
1276        return MaterialColors.getColor(view, bubbleToOnSurface(bubbleColor));
1277    }
1278
1279    public static ColorStateList bubbleToOnSurfaceColorStateList(
1280            final View view, final BubbleColor bubbleColor) {
1281        return ColorStateList.valueOf(bubbleToOnSurfaceColor(view, bubbleColor));
1282    }
1283
1284    private static @AttrRes int bubbleToOnSurface(final BubbleColor bubbleColor) {
1285        return switch (bubbleColor) {
1286            case SURFACE, SURFACE_HIGH -> com.google.android.material.R.attr.colorOnSurface;
1287            case PRIMARY -> com.google.android.material.R.attr.colorOnPrimaryContainer;
1288            case SECONDARY -> com.google.android.material.R.attr.colorOnSecondaryContainer;
1289            case TERTIARY -> com.google.android.material.R.attr.colorOnTertiaryContainer;
1290            case WARNING -> com.google.android.material.R.attr.colorOnErrorContainer;
1291        };
1292    }
1293
1294    public enum BubbleColor {
1295        SURFACE,
1296        SURFACE_HIGH,
1297        PRIMARY,
1298        SECONDARY,
1299        TERTIARY,
1300        WARNING;
1301
1302        private static final Collection<BubbleColor> SURFACES =
1303                Arrays.asList(BubbleColor.SURFACE, BubbleColor.SURFACE_HIGH);
1304    }
1305
1306    private static class BubbleDesign {
1307        public final boolean colorfulChatBubbles;
1308        public final boolean largeFont;
1309
1310        private BubbleDesign(final boolean colorfulChatBubbles, final boolean largeFont) {
1311            this.colorfulChatBubbles = colorfulChatBubbles;
1312            this.largeFont = largeFont;
1313        }
1314    }
1315
1316    private static class ViewHolder {
1317
1318        public MaterialButton load_more_messages;
1319        public ImageView edit_indicator;
1320        public RelativeLayout audioPlayer;
1321        protected LinearLayout message_box;
1322        protected MaterialButton download_button;
1323        protected ImageView image;
1324        protected ImageView indicator;
1325        protected ImageView indicatorReceived;
1326        protected TextView time;
1327        protected TextView messageBody;
1328        protected ImageView contact_picture;
1329        protected TextView status_message;
1330        protected TextView encryption;
1331        protected ChipGroup reactions;
1332    }
1333}