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