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