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