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