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