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