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