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