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