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