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.graphics.PorterDuff;
8import android.graphics.drawable.Drawable;
9import android.content.res.ColorStateList;
10import android.graphics.Typeface;
11import android.net.Uri;
12import android.os.AsyncTask;
13import android.os.Build;
14import android.preference.PreferenceManager;
15import android.text.Editable;
16import android.text.Spanned;
17import android.text.Spannable;
18import android.text.SpannableString;
19import android.text.SpannableStringBuilder;
20import android.text.style.ImageSpan;
21import android.text.style.ClickableSpan;
22import android.text.format.DateUtils;
23import android.text.style.ForegroundColorSpan;
24import android.text.style.RelativeSizeSpan;
25import android.text.style.StyleSpan;
26import android.text.style.URLSpan;
27import android.util.DisplayMetrics;
28import android.util.LruCache;
29import android.view.accessibility.AccessibilityEvent;
30import android.view.Gravity;
31import android.view.LayoutInflater;
32import android.view.MotionEvent;
33import android.util.Log;
34import android.view.View;
35import android.view.ViewGroup;
36import android.view.WindowManager;
37import android.widget.ArrayAdapter;
38import android.widget.Button;
39import android.widget.ImageView;
40import android.widget.LinearLayout;
41import android.widget.ListAdapter;
42import android.widget.ListView;
43import android.widget.RelativeLayout;
44import android.widget.TextView;
45import android.widget.Toast;
46
47import androidx.annotation.AttrRes;
48import androidx.annotation.ColorInt;
49import androidx.annotation.DrawableRes;
50import androidx.annotation.NonNull;
51import androidx.annotation.Nullable;
52import androidx.core.app.ActivityCompat;
53import androidx.core.content.ContextCompat;
54import androidx.core.content.res.ResourcesCompat;
55import androidx.core.widget.ImageViewCompat;
56import androidx.databinding.DataBindingUtil;
57
58import com.google.android.material.imageview.ShapeableImageView;
59import com.google.android.material.shape.CornerFamily;
60import com.google.android.material.shape.ShapeAppearanceModel;
61
62import com.cheogram.android.BobTransfer;
63import com.cheogram.android.EmojiSearch;
64import com.cheogram.android.GetThumbnailForCid;
65import com.cheogram.android.MessageTextActionModeCallback;
66import com.cheogram.android.SwipeDetector;
67import com.cheogram.android.Util;
68import com.cheogram.android.WebxdcPage;
69import com.cheogram.android.WebxdcUpdate;
70
71import androidx.emoji2.emojipicker.EmojiViewItem;
72import androidx.emoji2.emojipicker.RecentEmojiProvider;
73
74import com.google.android.material.button.MaterialButton;
75import com.google.android.material.chip.ChipGroup;
76import com.google.android.material.color.MaterialColors;
77import com.google.android.material.dialog.MaterialAlertDialogBuilder;
78import com.google.common.base.Joiner;
79import com.google.common.base.Strings;
80import com.google.common.collect.ImmutableList;
81import com.google.common.collect.ImmutableSet;
82
83import com.lelloman.identicon.view.GithubIdenticonView;
84
85import io.ipfs.cid.Cid;
86
87import java.io.IOException;
88import java.net.URI;
89import java.net.URISyntaxException;
90import java.security.NoSuchAlgorithmException;
91import java.util.HashMap;
92import java.util.List;
93import java.util.Map;
94import java.util.Locale;
95import java.util.function.Function;
96import java.util.regex.Matcher;
97import java.util.regex.Pattern;
98
99import me.saket.bettermovementmethod.BetterLinkMovementMethod;
100
101import net.fellbaum.jemoji.EmojiManager;
102
103import eu.siacs.conversations.AppSettings;
104import eu.siacs.conversations.Config;
105import eu.siacs.conversations.R;
106import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
107import eu.siacs.conversations.databinding.LinkDescriptionBinding;
108import eu.siacs.conversations.databinding.DialogAddReactionBinding;
109import eu.siacs.conversations.entities.Account;
110import eu.siacs.conversations.entities.Contact;
111import eu.siacs.conversations.entities.Conversation;
112import eu.siacs.conversations.entities.Conversational;
113import eu.siacs.conversations.entities.DownloadableFile;
114import eu.siacs.conversations.entities.Message.FileParams;
115import eu.siacs.conversations.entities.Message;
116import eu.siacs.conversations.entities.MucOptions;
117import eu.siacs.conversations.entities.Reaction;
118import eu.siacs.conversations.entities.Roster;
119import eu.siacs.conversations.entities.RtpSessionStatus;
120import eu.siacs.conversations.entities.Transferable;
121import eu.siacs.conversations.persistance.FileBackend;
122import eu.siacs.conversations.services.MessageArchiveService;
123import eu.siacs.conversations.services.NotificationService;
124import eu.siacs.conversations.ui.Activities;
125import eu.siacs.conversations.ui.BindingAdapters;
126import eu.siacs.conversations.ui.ConversationFragment;
127import eu.siacs.conversations.ui.ConversationsActivity;
128import eu.siacs.conversations.ui.XmppActivity;
129import eu.siacs.conversations.ui.service.AudioPlayer;
130import eu.siacs.conversations.ui.text.DividerSpan;
131import eu.siacs.conversations.ui.text.FixedURLSpan;
132import eu.siacs.conversations.ui.text.QuoteSpan;
133import eu.siacs.conversations.ui.util.Attachment;
134import eu.siacs.conversations.ui.util.AvatarWorkerTask;
135import eu.siacs.conversations.ui.util.MyLinkify;
136import eu.siacs.conversations.ui.util.QuoteHelper;
137import eu.siacs.conversations.ui.util.ShareUtil;
138import eu.siacs.conversations.ui.util.ViewUtil;
139import eu.siacs.conversations.utils.CryptoHelper;
140import eu.siacs.conversations.utils.Emoticons;
141import eu.siacs.conversations.utils.GeoHelper;
142import eu.siacs.conversations.utils.MessageUtils;
143import eu.siacs.conversations.utils.StylingHelper;
144import eu.siacs.conversations.utils.TimeFrameUtils;
145import eu.siacs.conversations.utils.UIHelper;
146import eu.siacs.conversations.xmpp.Jid;
147import eu.siacs.conversations.xmpp.mam.MamReference;
148import eu.siacs.conversations.xml.Element;
149import kotlin.coroutines.Continuation;
150
151import java.net.URI;
152import java.util.Arrays;
153import java.util.Collection;
154import java.util.List;
155import java.util.Locale;
156import java.util.function.Consumer;
157import java.util.regex.Matcher;
158import java.util.regex.Pattern;
159
160public class MessageAdapter extends ArrayAdapter<Message> {
161
162 public static final String DATE_SEPARATOR_BODY = "DATE_SEPARATOR";
163 private static final int SENT = 0;
164 private static final int RECEIVED = 1;
165 private static final int STATUS = 2;
166 private static final int DATE_SEPARATOR = 3;
167 private static final int RTP_SESSION = 4;
168 private final XmppActivity activity;
169 private final AudioPlayer audioPlayer;
170 private List<String> highlightedTerm = null;
171 private final DisplayMetrics metrics;
172 private ConversationFragment mConversationFragment = null;
173 private OnContactPictureClicked mOnContactPictureClickedListener;
174 private OnContactPictureClicked mOnMessageBoxClickedListener;
175 private OnContactPictureClicked mOnMessageBoxSwipedListener;
176 private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
177 private OnInlineImageLongClicked mOnInlineImageLongClickedListener;
178 private boolean mUseGreenBackground = false;
179 private BubbleDesign bubbleDesign = new BubbleDesign(false, false);
180 private final boolean mForceNames;
181 private final Map<String, WebxdcUpdate> lastWebxdcUpdate = new HashMap<>();
182 private String selectionUuid = null;
183 private final AppSettings appSettings;
184
185 public MessageAdapter(
186 final XmppActivity activity, final List<Message> messages, final boolean forceNames) {
187 super(activity, 0, messages);
188 this.audioPlayer = new AudioPlayer(this);
189 this.activity = activity;
190 metrics = getContext().getResources().getDisplayMetrics();
191 appSettings = new AppSettings(activity);
192 updatePreferences();
193 this.mForceNames = forceNames;
194 }
195
196 public MessageAdapter(final XmppActivity activity, final List<Message> messages) {
197 this(activity, messages, false);
198 }
199
200 private static void resetClickListener(View... views) {
201 for (View view : views) {
202 if (view != null) view.setOnClickListener(null);
203 }
204 }
205
206 public void flagScreenOn() {
207 activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
208 }
209
210 public void flagScreenOff() {
211 activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
212 }
213
214 public void setVolumeControl(final int stream) {
215 activity.setVolumeControlStream(stream);
216 }
217
218 public void setOnContactPictureClicked(OnContactPictureClicked listener) {
219 this.mOnContactPictureClickedListener = listener;
220 }
221
222 public void setOnMessageBoxClicked(OnContactPictureClicked listener) {
223 this.mOnMessageBoxClickedListener = listener;
224 }
225
226 public void setOnMessageBoxSwiped(OnContactPictureClicked listener) {
227 this.mOnMessageBoxSwipedListener = listener;
228 }
229
230 public void setConversationFragment(ConversationFragment frag) {
231 mConversationFragment = frag;
232 }
233
234 public void quoteText(String text) {
235 if (mConversationFragment != null) mConversationFragment.quoteText(text);
236 }
237
238 public boolean hasSelection() {
239 return selectionUuid != null;
240 }
241
242 public Activity getActivity() {
243 return activity;
244 }
245
246 public void setOnContactPictureLongClicked(OnContactPictureLongClicked listener) {
247 this.mOnContactPictureLongClickedListener = listener;
248 }
249
250 public void setOnInlineImageLongClicked(OnInlineImageLongClicked listener) {
251 this.mOnInlineImageLongClickedListener = listener;
252 }
253
254 @Override
255 public int getViewTypeCount() {
256 return 5;
257 }
258
259 private int getItemViewType(Message message) {
260 if (message.getType() == Message.TYPE_STATUS) {
261 if (DATE_SEPARATOR_BODY.equals(message.getBody())) {
262 return DATE_SEPARATOR;
263 } else {
264 return STATUS;
265 }
266 } else if (message.getType() == Message.TYPE_RTP_SESSION) {
267 return RTP_SESSION;
268 } else if (message.getStatus() <= Message.STATUS_RECEIVED) {
269 return RECEIVED;
270 } else {
271 return SENT;
272 }
273 }
274
275 @Override
276 public int getItemViewType(int position) {
277 return this.getItemViewType(getItem(position));
278 }
279
280 private void displayStatus(
281 final ViewHolder viewHolder,
282 final Message message,
283 final int type,
284 final BubbleColor bubbleColor) {
285 final int mergedStatus = message.getMergedStatus();
286 final boolean error;
287 if (viewHolder.indicatorReceived != null) {
288 viewHolder.indicatorReceived.setVisibility(View.GONE);
289 }
290 final Transferable transferable = message.getTransferable();
291 final boolean multiReceived =
292 message.getConversation().getMode() == Conversation.MODE_MULTI
293 && mergedStatus <= Message.STATUS_RECEIVED;
294 final String fileSize;
295 if (message.isFileOrImage()
296 || transferable != null
297 || MessageUtils.unInitiatedButKnownSize(message)) {
298 final FileParams params = message.getFileParams();
299 fileSize = params.size != null ? UIHelper.filesizeToString(params.size) : null;
300 if (message.getStatus() == Message.STATUS_SEND_FAILED
301 || (transferable != null
302 && (transferable.getStatus() == Transferable.STATUS_FAILED
303 || transferable.getStatus()
304 == Transferable.STATUS_CANCELLED))) {
305 error = true;
306 } else {
307 error = message.getStatus() == Message.STATUS_SEND_FAILED;
308 }
309 } else {
310 fileSize = null;
311 error = message.getStatus() == Message.STATUS_SEND_FAILED;
312 }
313 if (type == SENT && viewHolder.indicatorReceived != null) {
314 final @DrawableRes Integer receivedIndicator =
315 getMessageStatusAsDrawable(message, mergedStatus);
316 if (receivedIndicator == null) {
317 viewHolder.indicatorReceived.setVisibility(View.INVISIBLE);
318 } else {
319 viewHolder.indicatorReceived.setImageResource(receivedIndicator);
320 if (mergedStatus == Message.STATUS_SEND_FAILED) {
321 setImageTintError(viewHolder.indicatorReceived);
322 } else {
323 setImageTint(viewHolder.indicatorReceived, bubbleColor);
324 }
325 viewHolder.indicatorReceived.setVisibility(View.VISIBLE);
326 }
327 }
328 final var additionalStatusInfo = getAdditionalStatusInfo(message, mergedStatus);
329
330 if (error && type == SENT) {
331 viewHolder.time.setTextColor(
332 MaterialColors.getColor(
333 viewHolder.time, com.google.android.material.R.attr.colorError));
334 } else {
335 setTextColor(viewHolder.time, bubbleColor);
336 }
337 setTextColor(viewHolder.subject, bubbleColor);
338 if (message.getEncryption() == Message.ENCRYPTION_NONE) {
339 viewHolder.indicator.setVisibility(View.GONE);
340 } else {
341 boolean verified = false;
342 if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
343 final FingerprintStatus status =
344 message.getConversation()
345 .getAccount()
346 .getAxolotlService()
347 .getFingerprintTrust(message.getFingerprint());
348 if (status != null && status.isVerified()) {
349 verified = true;
350 }
351 }
352 if (verified) {
353 viewHolder.indicator.setImageResource(R.drawable.ic_verified_user_24dp);
354 } else {
355 viewHolder.indicator.setImageResource(R.drawable.ic_lock_24dp);
356 }
357 if (error && type == SENT) {
358 setImageTintError(viewHolder.indicator);
359 } else {
360 setImageTint(viewHolder.indicator, bubbleColor);
361 }
362 viewHolder.indicator.setVisibility(View.VISIBLE);
363 }
364
365 if (viewHolder.edit_indicator != null) {
366 if (message.edited()) {
367 viewHolder.edit_indicator.setVisibility(View.VISIBLE);
368 if (error && type == SENT) {
369 setImageTintError(viewHolder.edit_indicator);
370 } else {
371 setImageTint(viewHolder.edit_indicator, bubbleColor);
372 }
373 } else {
374 viewHolder.edit_indicator.setVisibility(View.GONE);
375 }
376 }
377
378 final String formattedTime =
379 UIHelper.readableTimeDifferenceFull(getContext(), message.getMergedTimeSent());
380 final String bodyLanguage = message.getBodyLanguage();
381 final ImmutableList.Builder<String> timeInfoBuilder = new ImmutableList.Builder<>();
382 if (message.getStatus() <= Message.STATUS_RECEIVED) {
383 timeInfoBuilder.add(formattedTime);
384 if (fileSize != null) {
385 timeInfoBuilder.add(fileSize);
386 }
387 if (mForceNames || multiReceived || (message.getTrueCounterpart() != null && message.getContact() != null)) {
388 final String displayName = UIHelper.getMessageDisplayName(message);
389 if (displayName != null) {
390 timeInfoBuilder.add(displayName);
391 }
392 }
393 if (bodyLanguage != null) {
394 timeInfoBuilder.add(bodyLanguage.toUpperCase(Locale.US));
395 }
396 } else {
397 if (bodyLanguage != null) {
398 timeInfoBuilder.add(bodyLanguage.toUpperCase(Locale.US));
399 }
400 if (fileSize != null) {
401 timeInfoBuilder.add(fileSize);
402 }
403 // for space reasons we display only 'additional status info' (send progress or concrete
404 // failure reason) or the time
405 if (additionalStatusInfo != null) {
406 timeInfoBuilder.add(additionalStatusInfo);
407 } else {
408 timeInfoBuilder.add(formattedTime);
409 }
410 }
411 final var timeInfo = timeInfoBuilder.build();
412 viewHolder.time.setText(Joiner.on(" \u00B7 ").join(timeInfo));
413 }
414
415 public static @DrawableRes Integer getMessageStatusAsDrawable(
416 final Message message, final int status) {
417 final var transferable = message.getTransferable();
418 return switch (status) {
419 case Message.STATUS_WAITING -> R.drawable.ic_more_horiz_24dp;
420 case Message.STATUS_UNSEND -> transferable == null ? null : R.drawable.ic_upload_24dp;
421 case Message.STATUS_SEND -> R.drawable.ic_done_24dp;
422 case Message.STATUS_SEND_RECEIVED, Message.STATUS_SEND_DISPLAYED -> R.drawable
423 .ic_done_all_24dp;
424 case Message.STATUS_SEND_FAILED -> {
425 final String errorMessage = message.getErrorMessage();
426 if (Message.ERROR_MESSAGE_CANCELLED.equals(errorMessage)) {
427 yield R.drawable.ic_cancel_24dp;
428 } else {
429 yield R.drawable.ic_error_24dp;
430 }
431 }
432 case Message.STATUS_OFFERED -> R.drawable.ic_p2p_24dp;
433 default -> null;
434 };
435 }
436
437 @Nullable
438 private String getAdditionalStatusInfo(final Message message, final int mergedStatus) {
439 final String additionalStatusInfo;
440 if (mergedStatus == Message.STATUS_SEND_FAILED) {
441 final String errorMessage = Strings.nullToEmpty(message.getErrorMessage());
442 final String[] errorParts = errorMessage.split("\\u001f", 2);
443 if (errorParts.length == 2 && errorParts[0].equals("file-too-large")) {
444 additionalStatusInfo = getContext().getString(R.string.file_too_large);
445 } else {
446 additionalStatusInfo = null;
447 }
448 } else if (mergedStatus == Message.STATUS_UNSEND) {
449 final var transferable = message.getTransferable();
450 if (transferable == null) {
451 return null;
452 }
453 return getContext().getString(R.string.sending_file, transferable.getProgress());
454 } else {
455 additionalStatusInfo = null;
456 }
457 return additionalStatusInfo;
458 }
459
460 private void displayInfoMessage(
461 ViewHolder viewHolder, CharSequence text, final BubbleColor bubbleColor) {
462 viewHolder.download_button.setVisibility(View.GONE);
463 viewHolder.audioPlayer.setVisibility(View.GONE);
464 viewHolder.image.setVisibility(View.GONE);
465 viewHolder.messageBody.setVisibility(View.VISIBLE);
466 viewHolder.messageBody.setText(text);
467 viewHolder.messageBody.setTextColor(
468 bubbleToOnSurfaceVariant(viewHolder.messageBody, bubbleColor));
469 viewHolder.messageBody.setTextIsSelectable(false);
470 }
471
472 private void displayEmojiMessage(
473 final ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, int type) {
474 displayTextMessage(viewHolder, message, bubbleColor, type);
475 viewHolder.download_button.setVisibility(View.GONE);
476 viewHolder.audioPlayer.setVisibility(View.GONE);
477 viewHolder.image.setVisibility(View.GONE);
478 viewHolder.messageBody.setVisibility(View.VISIBLE);
479 setTextColor(viewHolder.messageBody, bubbleColor);
480 final var body = getSpannableBody(message);
481 ImageSpan[] imageSpans = body.getSpans(0, body.length(), ImageSpan.class);
482 float size = imageSpans.length == 1 || Emoticons.isEmoji(body.toString()) ? 5.0f : 2.0f;
483 body.setSpan(
484 new RelativeSizeSpan(size), 0, body.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
485 viewHolder.messageBody.setText(body);
486 }
487
488 private void applyQuoteSpan(
489 final TextView textView,
490 Editable body,
491 int start,
492 int end,
493 final BubbleColor bubbleColor,
494 final boolean makeEdits) {
495 if (makeEdits && start > 1 && !"\n\n".equals(body.subSequence(start - 2, start).toString())) {
496 body.insert(start++, "\n");
497 body.setSpan(
498 new DividerSpan(false), start - 2, start, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
499 end++;
500 }
501 if (makeEdits && end < body.length() - 1 && !"\n\n".equals(body.subSequence(end, end + 2).toString())) {
502 body.insert(end, "\n");
503 body.setSpan(
504 new DividerSpan(false),
505 end,
506 end + ("\n".equals(body.subSequence(end + 1, end + 2).toString()) ? 2 : 1),
507 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
508 );
509 }
510 final DisplayMetrics metrics = getContext().getResources().getDisplayMetrics();
511 body.setSpan(
512 new QuoteSpan(bubbleToOnSurfaceVariant(textView, bubbleColor), metrics),
513 start,
514 end,
515 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
516 }
517
518 public boolean handleTextQuotes(final TextView textView, final Editable body) {
519 return handleTextQuotes(textView, body, true);
520 }
521
522 public boolean handleTextQuotes(final TextView textView, final Editable body, final boolean deleteMarkers) {
523 final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
524 final BubbleColor bubbleColor = colorfulBackground ? (deleteMarkers ? BubbleColor.SECONDARY : BubbleColor.TERTIARY) : BubbleColor.SURFACE;
525 return handleTextQuotes(textView, body, bubbleColor, deleteMarkers);
526 }
527
528 /**
529 * Applies QuoteSpan to group of lines which starts with > or » characters. Appends likebreaks
530 * and applies DividerSpan to them to show a padding between quote and text.
531 */
532 public boolean handleTextQuotes(
533 final TextView textView,
534 final Editable body,
535 final BubbleColor bubbleColor,
536 final boolean deleteMarkers) {
537 boolean startsWithQuote = false;
538 int quoteDepth = 1;
539 while (QuoteHelper.bodyContainsQuoteStart(body) && quoteDepth <= Config.QUOTE_MAX_DEPTH) {
540 char previous = '\n';
541 int lineStart = -1;
542 int lineTextStart = -1;
543 int quoteStart = -1;
544 int skipped = 0;
545 for (int i = 0; i <= body.length(); i++) {
546 if (!deleteMarkers && QuoteHelper.isRelativeSizeSpanned(body, i)) {
547 skipped++;
548 continue;
549 }
550 char current = body.length() > i ? body.charAt(i) : '\n';
551 if (lineStart == -1) {
552 if (previous == '\n') {
553 if (i < body.length() && QuoteHelper.isPositionQuoteStart(body, i)) {
554 // Line start with quote
555 lineStart = i;
556 if (quoteStart == -1) quoteStart = i - skipped;
557 if (i == 0) startsWithQuote = true;
558 } else if (quoteStart >= 0) {
559 // Line start without quote, apply spans there
560 applyQuoteSpan(textView, body, quoteStart, i - 1, bubbleColor, deleteMarkers);
561 quoteStart = -1;
562 }
563 }
564 } else {
565 // Remove extra spaces between > and first character in the line
566 // > character will be removed too
567 if (current != ' ' && lineTextStart == -1) {
568 lineTextStart = i;
569 }
570 if (current == '\n') {
571 if (deleteMarkers) {
572 i -= lineTextStart - lineStart;
573 body.delete(lineStart, lineTextStart);
574 if (i == lineStart) {
575 // Avoid empty lines because span over empty line can be hidden
576 body.insert(i++, " ");
577 }
578 } else {
579 body.setSpan(new RelativeSizeSpan(i - (lineTextStart - lineStart) == lineStart ? 1 : 0), lineStart, lineTextStart, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE | StylingHelper.XHTML_REMOVE << Spanned.SPAN_USER_SHIFT);
580 }
581 lineStart = -1;
582 lineTextStart = -1;
583 }
584 }
585 previous = current;
586 skipped = 0;
587 }
588 if (quoteStart >= 0) {
589 // Apply spans to finishing open quote
590 applyQuoteSpan(textView, body, quoteStart, body.length(), bubbleColor, deleteMarkers);
591 }
592 quoteDepth++;
593 }
594 return startsWithQuote;
595 }
596
597 private SpannableStringBuilder getSpannableBody(final Message message) {
598 Drawable fallbackImg = ResourcesCompat.getDrawable(activity.getResources(), R.drawable.ic_photo_24dp, null);
599 return message.getMergedBody(new Thumbnailer(message), fallbackImg);
600 }
601
602 private void displayTextMessage(
603 final ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
604 viewHolder.inReplyToQuote.setVisibility(View.GONE);
605 viewHolder.download_button.setVisibility(View.GONE);
606 viewHolder.image.setVisibility(View.GONE);
607 viewHolder.audioPlayer.setVisibility(View.GONE);
608 viewHolder.messageBody.setVisibility(View.VISIBLE);
609 setTextColor(viewHolder.messageBody, bubbleColor);
610 setTextSize(viewHolder.messageBody, this.bubbleDesign.largeFont);
611
612 final ViewGroup.LayoutParams layoutParams = viewHolder.messageBody.getLayoutParams();
613 layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT;
614 viewHolder.messageBody.setLayoutParams(layoutParams);
615
616 final ViewGroup.LayoutParams qlayoutParams = viewHolder.inReplyToQuote.getLayoutParams();
617 qlayoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT;
618 viewHolder.messageBody.setLayoutParams(qlayoutParams);
619
620 viewHolder.messageBody.setTypeface(null, Typeface.NORMAL);
621
622 if (message.getBody() != null && !message.getBody().equals("")) {
623 viewHolder.messageBody.setTextIsSelectable(true);
624 viewHolder.messageBody.setVisibility(View.VISIBLE);
625 final String nick = UIHelper.getMessageDisplayName(message);
626 SpannableStringBuilder body = getSpannableBody(message);
627 final var processMarkup = body.getSpans(0, body.length(), Message.PlainTextSpan.class).length > 0;
628 if (body.length() > Config.MAX_DISPLAY_MESSAGE_CHARS) {
629 body = new SpannableStringBuilder(body, 0, Config.MAX_DISPLAY_MESSAGE_CHARS);
630 body.append("\u2026");
631 }
632 Message.MergeSeparator[] mergeSeparators =
633 body.getSpans(0, body.length(), Message.MergeSeparator.class);
634 for (Message.MergeSeparator mergeSeparator : mergeSeparators) {
635 int start = body.getSpanStart(mergeSeparator);
636 int end = body.getSpanEnd(mergeSeparator);
637 body.setSpan(new DividerSpan(true), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
638 }
639 if (processMarkup) StylingHelper.format(body, viewHolder.messageBody.getCurrentTextColor());
640 MyLinkify.addLinks(body, message.getConversation().getAccount(), message.getConversation().getJid());
641 boolean startsWithQuote = processMarkup ? handleTextQuotes(viewHolder.messageBody, body, bubbleColor, true) : false;
642 for (final android.text.style.QuoteSpan quote : body.getSpans(0, body.length(), android.text.style.QuoteSpan.class)) {
643 int start = body.getSpanStart(quote);
644 int end = body.getSpanEnd(quote);
645 if (start < 0 || end < 0) continue;
646
647 body.removeSpan(quote);
648 applyQuoteSpan(viewHolder.messageBody, body, start, end, bubbleColor, true);
649 if (start == 0) {
650 if (message.getInReplyTo() == null) {
651 startsWithQuote = true;
652 } else {
653 viewHolder.inReplyToQuote.setText(body.subSequence(start, end));
654 viewHolder.inReplyToQuote.setVisibility(View.VISIBLE);
655 body.delete(start, end);
656 while (body.length() > start && body.charAt(start) == '\n') body.delete(start, 1); // Newlines after quote
657 continue;
658 }
659 }
660 }
661 boolean hasMeCommand = body.toString().startsWith(Message.ME_COMMAND);
662 if (hasMeCommand) {
663 body = body.replace(0, Message.ME_COMMAND.length(), nick + " ");
664 }
665 if (!message.isPrivateMessage()) {
666 if (hasMeCommand && body.length() > nick.length()) {
667 body.setSpan(
668 new StyleSpan(Typeface.BOLD_ITALIC),
669 0,
670 nick.length(),
671 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
672 }
673 } else {
674 String privateMarker;
675 if (message.getStatus() <= Message.STATUS_RECEIVED) {
676 privateMarker = activity.getString(R.string.private_message);
677 } else {
678 Jid cp = message.getCounterpart();
679 privateMarker =
680 activity.getString(
681 R.string.private_message_to,
682 Strings.nullToEmpty(cp == null ? null : cp.getResource()));
683 }
684 body.insert(0, privateMarker);
685 int privateMarkerIndex = privateMarker.length();
686 if (startsWithQuote) {
687 body.insert(privateMarkerIndex, "\n\n");
688 body.setSpan(
689 new DividerSpan(false),
690 privateMarkerIndex,
691 privateMarkerIndex + 2,
692 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
693 } else {
694 body.insert(privateMarkerIndex, " ");
695 }
696 body.setSpan(
697 new ForegroundColorSpan(
698 bubbleToOnSurfaceVariant(viewHolder.messageBody, bubbleColor)),
699 0,
700 privateMarkerIndex,
701 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
702 body.setSpan(
703 new StyleSpan(Typeface.BOLD),
704 0,
705 privateMarkerIndex,
706 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
707 if (hasMeCommand) {
708 body.setSpan(
709 new StyleSpan(Typeface.BOLD_ITALIC),
710 privateMarkerIndex + 1,
711 privateMarkerIndex + 1 + nick.length(),
712 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
713 }
714 }
715 if (message.getConversation().getMode() == Conversation.MODE_MULTI
716 && message.getStatus() == Message.STATUS_RECEIVED) {
717 if (message.getConversation() instanceof Conversation conversation) {
718 Pattern pattern =
719 NotificationService.generateNickHighlightPattern(
720 conversation.getMucOptions().getActualNick());
721 Matcher matcher = pattern.matcher(body);
722 while (matcher.find()) {
723 body.setSpan(
724 new StyleSpan(Typeface.BOLD),
725 matcher.start(),
726 matcher.end(),
727 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
728 }
729
730 pattern = NotificationService.generateNickHighlightPattern(conversation.getMucOptions().getActualName());
731 matcher = pattern.matcher(body);
732 while (matcher.find()) {
733 body.setSpan(new StyleSpan(Typeface.BOLD), matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
734 }
735 }
736 }
737 for (final var emoji : EmojiManager.extractEmojisInOrderWithIndex(body.toString())) {
738 var end = emoji.getCharIndex() + emoji.getEmoji().getEmoji().length();
739 if (body.length() > end && body.charAt(end) == '\uFE0F') end++;
740 body.setSpan(
741 new RelativeSizeSpan(1.2f),
742 emoji.getCharIndex(),
743 end,
744 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
745 }
746 // Make custom emoji bigger too, to match emoji
747 for (final var span : body.getSpans(0, body.length(), com.cheogram.android.InlineImageSpan.class)) {
748 body.setSpan(
749 new RelativeSizeSpan(1.2f),
750 body.getSpanStart(span),
751 body.getSpanEnd(span),
752 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
753 }
754
755 if (highlightedTerm != null) {
756 StylingHelper.highlight(viewHolder.messageBody, body, highlightedTerm);
757 }
758
759 viewHolder.messageBody.setAutoLinkMask(0);
760 viewHolder.messageBody.setText(body);
761 if (body.length() <= 0) viewHolder.messageBody.setVisibility(View.GONE);
762 BetterLinkMovementMethod method = new BetterLinkMovementMethod() {
763 @Override
764 protected void dispatchUrlLongClick(TextView tv, ClickableSpan span) {
765 if (span instanceof URLSpan || mOnInlineImageLongClickedListener == null) {
766 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
767 super.dispatchUrlLongClick(tv, span);
768 return;
769 }
770
771 Spannable body = (Spannable) tv.getText();
772 ImageSpan[] imageSpans = body.getSpans(body.getSpanStart(span), body.getSpanEnd(span), ImageSpan.class);
773 if (imageSpans.length > 0) {
774 Uri uri = Uri.parse(imageSpans[0].getSource());
775 Cid cid = BobTransfer.cid(uri);
776 if (cid == null) return;
777 if (mOnInlineImageLongClickedListener.onInlineImageLongClicked(cid)) {
778 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
779 }
780 }
781 }
782 };
783 method.setOnLinkLongClickListener((tv, url) -> {
784 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
785 ShareUtil.copyLinkToClipboard(activity, url);
786 return true;
787 });
788 viewHolder.messageBody.setMovementMethod(method);
789 } else {
790 viewHolder.messageBody.setText("");
791 viewHolder.messageBody.setTextIsSelectable(false);
792 toggleWhisperInfo(viewHolder, message, bubbleColor);
793 }
794 }
795
796 private void displayDownloadableMessage(
797 ViewHolder viewHolder,
798 final Message message,
799 String text,
800 final BubbleColor bubbleColor, final int type) {
801 displayTextMessage(viewHolder, message, bubbleColor, type);
802 viewHolder.image.setVisibility(View.GONE);
803 List<Element> thumbs = message.getFileParams() != null ? message.getFileParams().getThumbnails() : null;
804 if (thumbs != null && !thumbs.isEmpty()) {
805 for (Element thumb : thumbs) {
806 Uri uri = Uri.parse(thumb.getAttribute("uri"));
807 if (uri.getScheme().equals("data")) {
808 String[] parts = uri.getSchemeSpecificPart().split(",", 2);
809 parts = parts[0].split(";");
810 if (!parts[0].equals("image/blurhash") && !parts[0].equals("image/thumbhash") && !parts[0].equals("image/jpeg") && !parts[0].equals("image/png") && !parts[0].equals("image/webp") && !parts[0].equals("image/gif")) continue;
811 } else if (uri.getScheme().equals("cid")) {
812 Cid cid = BobTransfer.cid(uri);
813 if (cid == null) continue;
814 DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
815 if (f == null || !f.canRead()) {
816 if (!message.trusted() && !message.getConversation().canInferPresence()) continue;
817
818 try {
819 new BobTransfer(BobTransfer.uri(cid), message.getConversation().getAccount(), message.getCounterpart(), activity.xmppConnectionService).start();
820 } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
821 continue;
822 }
823 } else {
824 continue;
825 }
826
827 int width = message.getFileParams().width;
828 if (width < 1 && thumb.getAttribute("width") != null) width = Integer.parseInt(thumb.getAttribute("width"));
829 if (width < 1) width = 1920;
830
831 int height = message.getFileParams().height;
832 if (height < 1 && thumb.getAttribute("height") != null) height = Integer.parseInt(thumb.getAttribute("height"));
833 if (height < 1) height = 1080;
834
835 viewHolder.image.setVisibility(View.VISIBLE);
836 imagePreviewLayout(width, height, viewHolder.image, message.getInReplyTo() != null, true, type, viewHolder);
837 activity.loadBitmap(message, viewHolder.image);
838 viewHolder.image.setOnClickListener(v -> ConversationFragment.downloadFile(activity, message));
839
840 break;
841 }
842 }
843 viewHolder.audioPlayer.setVisibility(View.GONE);
844 viewHolder.download_button.setVisibility(View.VISIBLE);
845 viewHolder.download_button.setText(text);
846 final var attachment = Attachment.of(message);
847 final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
848 viewHolder.download_button.setIconResource(imageResource);
849 viewHolder.download_button.setOnClickListener(
850 v -> ConversationFragment.downloadFile(activity, message));
851 }
852
853 private void displayWebxdcMessage(ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
854 Cid webxdcCid = message.getFileParams().getCids().get(0);
855 WebxdcPage webxdc = new WebxdcPage(activity, webxdcCid, message, activity.xmppConnectionService);
856 displayTextMessage(viewHolder, message, bubbleColor, type);
857 viewHolder.image.setVisibility(View.GONE);
858 viewHolder.audioPlayer.setVisibility(View.GONE);
859 viewHolder.download_button.setVisibility(View.VISIBLE);
860 viewHolder.download_button.setIconResource(0);
861 viewHolder.download_button.setText("Open " + webxdc.getName());
862 viewHolder.download_button.setOnClickListener(v -> {
863 Conversation conversation = (Conversation) message.getConversation();
864 if (!conversation.switchToSession("webxdc\0" + message.getUuid())) {
865 conversation.startWebxdc(webxdc);
866 }
867 });
868 viewHolder.image.setOnClickListener(v -> {
869 Conversation conversation = (Conversation) message.getConversation();
870 if (!conversation.switchToSession("webxdc\0" + message.getUuid())) {
871 conversation.startWebxdc(webxdc);
872 }
873 });
874
875 final WebxdcUpdate lastUpdate;
876 synchronized(lastWebxdcUpdate) { lastUpdate = lastWebxdcUpdate.get(message.getUuid()); }
877 if (lastUpdate == null) {
878 new Thread(() -> {
879 final WebxdcUpdate update = activity.xmppConnectionService.findLastWebxdcUpdate(message);
880 if (update != null) {
881 synchronized(lastWebxdcUpdate) { lastWebxdcUpdate.put(message.getUuid(), update); }
882 activity.xmppConnectionService.updateConversationUi();
883 }
884 }).start();
885 } else {
886 if (lastUpdate != null && (lastUpdate.getSummary() != null || lastUpdate.getDocument() != null)) {
887 viewHolder.messageBody.setVisibility(View.VISIBLE);
888 viewHolder.messageBody.setText(
889 (lastUpdate.getDocument() == null ? "" : lastUpdate.getDocument() + "\n") +
890 (lastUpdate.getSummary() == null ? "" : lastUpdate.getSummary())
891 );
892 }
893 }
894
895 final LruCache<String, Drawable> cache = activity.xmppConnectionService.getDrawableCache();
896 final Drawable d = cache.get("webxdc:icon:" + webxdcCid);
897 if (d == null) {
898 new Thread(() -> {
899 Drawable icon = webxdc.getIcon();
900 if (icon != null) {
901 cache.put("webxdc:icon:" + webxdcCid, icon);
902 activity.xmppConnectionService.updateConversationUi();
903 }
904 }).start();
905 } else {
906 viewHolder.image.setVisibility(View.VISIBLE);
907 viewHolder.image.setImageDrawable(d);
908 imagePreviewLayout(d.getIntrinsicWidth(), d.getIntrinsicHeight(), viewHolder.image, message.getInReplyTo() != null, true, type, viewHolder);
909 }
910 }
911
912 private void displayOpenableMessage(
913 ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
914 displayTextMessage(viewHolder, message, bubbleColor, type);
915 viewHolder.image.setVisibility(View.GONE);
916 viewHolder.audioPlayer.setVisibility(View.GONE);
917 viewHolder.download_button.setVisibility(View.VISIBLE);
918 viewHolder.download_button.setText(
919 activity.getString(
920 R.string.open_x_file,
921 UIHelper.getFileDescriptionString(activity, message)));
922 final var attachment = Attachment.of(message);
923 final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
924 viewHolder.download_button.setIconResource(imageResource);
925 viewHolder.download_button.setOnClickListener(v -> openDownloadable(message));
926 }
927
928 private void displayURIMessage(
929 ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
930 displayTextMessage(viewHolder, message, bubbleColor, type);
931 viewHolder.messageBody.setVisibility(View.GONE);
932 viewHolder.image.setVisibility(View.GONE);
933 viewHolder.audioPlayer.setVisibility(View.GONE);
934 viewHolder.download_button.setVisibility(View.VISIBLE);
935 final var uri = message.wholeIsKnownURI();
936 if ("bitcoin".equals(uri.getScheme())) {
937 final var amount = uri.getQueryParameter("amount");
938 final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
939 viewHolder.download_button.setIconResource(R.drawable.bitcoin_24dp);
940 viewHolder.download_button.setText("Send " + formattedAmount + "Bitcoin");
941 } else if ("bitcoincash".equals(uri.getScheme())) {
942 final var amount = uri.getQueryParameter("amount");
943 final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
944 viewHolder.download_button.setIconResource(R.drawable.bitcoin_cash_24dp);
945 viewHolder.download_button.setText("Send " + formattedAmount + "Bitcoin Cash");
946 } else if ("monero".equals(uri.getScheme())) {
947 final var amount = uri.getQueryParameter("tx_amount");
948 final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
949 viewHolder.download_button.setIconResource(R.drawable.monero_24dp);
950 viewHolder.download_button.setText("Send " + formattedAmount + "Monero");
951 } else if ("wownero".equals(uri.getScheme())) {
952 final var amount = uri.getQueryParameter("tx_amount");
953 final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
954 viewHolder.download_button.setIconResource(R.drawable.wownero_24dp);
955 viewHolder.download_button.setText("Send " + formattedAmount + "Wownero");
956 }
957 viewHolder.download_button.setOnClickListener(v -> new FixedURLSpan(message.getRawBody()).onClick(v));
958 }
959
960 private void displayLocationMessage(
961 ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
962 displayTextMessage(viewHolder, message, bubbleColor, type);
963 viewHolder.messageBody.setVisibility(View.GONE);
964 viewHolder.image.setVisibility(View.GONE);
965 viewHolder.audioPlayer.setVisibility(View.GONE);
966 viewHolder.download_button.setVisibility(View.VISIBLE);
967 viewHolder.download_button.setText(R.string.show_location);
968 final var attachment = Attachment.of(message);
969 final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
970 viewHolder.download_button.setIconResource(imageResource);
971 viewHolder.download_button.setOnClickListener(v -> showLocation(message));
972 }
973
974 private void displayAudioMessage(
975 ViewHolder viewHolder, Message message, final BubbleColor bubbleColor, final int type) {
976 displayTextMessage(viewHolder, message, bubbleColor, type);
977 viewHolder.image.setVisibility(View.GONE);
978 viewHolder.download_button.setVisibility(View.GONE);
979 final RelativeLayout audioPlayer = viewHolder.audioPlayer;
980 audioPlayer.setVisibility(View.VISIBLE);
981 AudioPlayer.ViewHolder.get(audioPlayer).setBubbleColor(bubbleColor);
982 this.audioPlayer.init(audioPlayer, message);
983 }
984
985 private void displayMediaPreviewMessage(
986 ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
987 displayTextMessage(viewHolder, message, bubbleColor, type);
988 viewHolder.download_button.setVisibility(View.GONE);
989 viewHolder.audioPlayer.setVisibility(View.GONE);
990 viewHolder.image.setVisibility(View.VISIBLE);
991 final FileParams params = message.getFileParams();
992 imagePreviewLayout(params.width, params.height, viewHolder.image, message.getInReplyTo() != null, viewHolder.messageBody.getVisibility() != View.GONE, type, viewHolder);
993 activity.loadBitmap(message, viewHolder.image);
994 viewHolder.image.setOnClickListener(v -> openDownloadable(message));
995 }
996
997 private void imagePreviewLayout(int w, int h, ShapeableImageView image, boolean otherAbove, boolean otherBelow, int type, ViewHolder viewHolder) {
998 final float target = activity.getResources().getDimension(R.dimen.image_preview_width);
999 final int scaledW;
1000 final int scaledH;
1001 if (Math.max(h, w) * metrics.density <= target) {
1002 scaledW = (int) (w * metrics.density);
1003 scaledH = (int) (h * metrics.density);
1004 } else if (Math.max(h, w) <= target) {
1005 scaledW = w;
1006 scaledH = h;
1007 } else if (w <= h) {
1008 scaledW = (int) (w / ((double) h / target));
1009 scaledH = (int) target;
1010 } else {
1011 scaledW = (int) target;
1012 scaledH = (int) (h / ((double) w / target));
1013 }
1014 final var bodyWidth = Math.max(viewHolder.messageBody.getWidth(), viewHolder.download_button.getWidth() + (20 * metrics.density));
1015 var targetImageWidth = 200 * metrics.density;
1016 if (!otherBelow) targetImageWidth = 110 * metrics.density;
1017 if (bodyWidth > 0 && bodyWidth < targetImageWidth) targetImageWidth = bodyWidth;
1018 final var small = scaledW < targetImageWidth;
1019 final LinearLayout.LayoutParams layoutParams =
1020 new LinearLayout.LayoutParams(scaledW, scaledH);
1021 image.setLayoutParams(layoutParams);
1022
1023 final var bubbleRadius = activity.getResources().getDimension(R.dimen.bubble_radius);
1024 var shape = new ShapeAppearanceModel.Builder();
1025 if (!otherAbove) {
1026 shape = shape.setTopRightCorner(CornerFamily.ROUNDED, bubbleRadius);
1027 if (type == SENT) {
1028 shape = shape.setTopLeftCorner(CornerFamily.ROUNDED, bubbleRadius);
1029 }
1030 }
1031 if (small) {
1032 final var imageRadius = activity.getResources().getDimension(R.dimen.image_radius);
1033 shape = shape.setAllCorners(CornerFamily.ROUNDED, imageRadius);
1034 image.setPadding(0, (int)(8 * metrics.density), 0, 0);
1035 } else {
1036 image.setPadding(0, 0, 0, 0);
1037 }
1038 image.setShapeAppearanceModel(shape.build());
1039
1040 if (!small) {
1041 final ViewGroup.LayoutParams blayoutParams = viewHolder.messageBody.getLayoutParams();
1042 blayoutParams.width = (int) (scaledW - (22 * metrics.density));
1043 viewHolder.messageBody.setLayoutParams(blayoutParams);
1044
1045 final ViewGroup.LayoutParams qlayoutParams = viewHolder.inReplyToQuote.getLayoutParams();
1046 qlayoutParams.width = (int) (scaledW - (22 * metrics.density));
1047 viewHolder.messageBody.setLayoutParams(qlayoutParams);
1048 }
1049 }
1050
1051 private void toggleWhisperInfo(
1052 ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor) {
1053 if (message.isPrivateMessage()) {
1054 final String privateMarker;
1055 if (message.getStatus() <= Message.STATUS_RECEIVED) {
1056 privateMarker = activity.getString(R.string.private_message);
1057 } else {
1058 Jid cp = message.getCounterpart();
1059 privateMarker =
1060 activity.getString(
1061 R.string.private_message_to,
1062 Strings.nullToEmpty(cp == null ? null : cp.getResource()));
1063 }
1064 final SpannableString body = new SpannableString(privateMarker);
1065 body.setSpan(
1066 new ForegroundColorSpan(
1067 bubbleToOnSurfaceVariant(viewHolder.messageBody, bubbleColor)),
1068 0,
1069 privateMarker.length(),
1070 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1071 body.setSpan(
1072 new StyleSpan(Typeface.BOLD),
1073 0,
1074 privateMarker.length(),
1075 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1076 viewHolder.messageBody.setText(body);
1077 viewHolder.messageBody.setVisibility(View.VISIBLE);
1078 } else {
1079 viewHolder.messageBody.setVisibility(View.GONE);
1080 }
1081 }
1082
1083 private void loadMoreMessages(Conversation conversation) {
1084 conversation.setLastClearHistory(0, null);
1085 activity.xmppConnectionService.updateConversation(conversation);
1086 conversation.setHasMessagesLeftOnServer(true);
1087 conversation.setFirstMamReference(null);
1088 long timestamp = conversation.getLastMessageTransmitted().getTimestamp();
1089 if (timestamp == 0) {
1090 timestamp = System.currentTimeMillis();
1091 }
1092 conversation.messagesLoaded.set(true);
1093 MessageArchiveService.Query query =
1094 activity.xmppConnectionService
1095 .getMessageArchiveService()
1096 .query(conversation, new MamReference(0), timestamp, false);
1097 if (query != null) {
1098 Toast.makeText(activity, R.string.fetching_history_from_server, Toast.LENGTH_LONG)
1099 .show();
1100 } else {
1101 Toast.makeText(
1102 activity,
1103 R.string.not_fetching_history_retention_period,
1104 Toast.LENGTH_SHORT)
1105 .show();
1106 }
1107 }
1108
1109 @Override
1110 public View getView(final int position, View view, final @NonNull ViewGroup parent) {
1111 final Message message = getItem(position);
1112 final boolean omemoEncryption = message.getEncryption() == Message.ENCRYPTION_AXOLOTL;
1113 final boolean isInValidSession =
1114 message.isValidInSession() && (!omemoEncryption || message.isTrusted());
1115 final Conversational conversation = message.getConversation();
1116 final Account account = conversation.getAccount();
1117 final List<Element> commands = message.getCommands();
1118 final int type = getItemViewType(position);
1119 ViewHolder viewHolder;
1120 if (view == null) {
1121 viewHolder = new ViewHolder();
1122 switch (type) {
1123 case DATE_SEPARATOR:
1124 view =
1125 activity.getLayoutInflater()
1126 .inflate(R.layout.item_message_date_bubble, parent, false);
1127 viewHolder.status_message = view.findViewById(R.id.message_body);
1128 viewHolder.message_box = view.findViewById(R.id.message_box);
1129 break;
1130 case RTP_SESSION:
1131 view =
1132 activity.getLayoutInflater()
1133 .inflate(R.layout.item_message_rtp_session, parent, false);
1134 viewHolder.status_message = view.findViewById(R.id.message_body);
1135 viewHolder.message_box = view.findViewById(R.id.message_box);
1136 viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
1137 break;
1138 case SENT:
1139 view = activity.getLayoutInflater().inflate(R.layout.item_message_sent, parent, false);
1140 viewHolder.status_line = view.findViewById(R.id.status_line);
1141 viewHolder.message_box_inner = view.findViewById(R.id.message_box_inner);
1142 viewHolder.message_box = view.findViewById(R.id.message_box);
1143 viewHolder.contact_picture = view.findViewById(R.id.message_photo);
1144 viewHolder.download_button = view.findViewById(R.id.download_button);
1145 viewHolder.indicator = view.findViewById(R.id.security_indicator);
1146 viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
1147 viewHolder.image = view.findViewById(R.id.message_image);
1148 viewHolder.messageBody = view.findViewById(R.id.message_body);
1149 viewHolder.time = view.findViewById(R.id.message_time);
1150 viewHolder.subject = view.findViewById(R.id.message_subject);
1151 viewHolder.inReplyTo = view.findViewById(R.id.in_reply_to);
1152 viewHolder.inReplyToBox = view.findViewById(R.id.in_reply_to_box);
1153 viewHolder.inReplyToQuote = view.findViewById(R.id.in_reply_to_quote);
1154 viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
1155 viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
1156 viewHolder.link_descriptions = view.findViewById(R.id.link_descriptions);
1157 viewHolder.thread_identicon = view.findViewById(R.id.thread_identicon);
1158 viewHolder.reactions = view.findViewById(R.id.reactions);
1159 break;
1160 case RECEIVED:
1161 view = activity.getLayoutInflater().inflate(R.layout.item_message_received, parent, false);
1162 viewHolder.status_line = view.findViewById(R.id.status_line);
1163 viewHolder.message_box_inner = view.findViewById(R.id.message_box_inner);
1164 viewHolder.message_box = view.findViewById(R.id.message_box);
1165 viewHolder.contact_picture = view.findViewById(R.id.message_photo);
1166 viewHolder.download_button = view.findViewById(R.id.download_button);
1167 viewHolder.indicator = view.findViewById(R.id.security_indicator);
1168 viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
1169 viewHolder.image = view.findViewById(R.id.message_image);
1170 viewHolder.messageBody = view.findViewById(R.id.message_body);
1171 viewHolder.time = view.findViewById(R.id.message_time);
1172 viewHolder.subject = view.findViewById(R.id.message_subject);
1173 viewHolder.inReplyTo = view.findViewById(R.id.in_reply_to);
1174 viewHolder.inReplyToQuote = view.findViewById(R.id.in_reply_to_quote);
1175 viewHolder.inReplyToBox = view.findViewById(R.id.in_reply_to_box);
1176 viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
1177 viewHolder.encryption = view.findViewById(R.id.message_encryption);
1178 viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
1179 viewHolder.commands_list = view.findViewById(R.id.commands_list);
1180 viewHolder.link_descriptions = view.findViewById(R.id.link_descriptions);
1181 viewHolder.thread_identicon = view.findViewById(R.id.thread_identicon);
1182 viewHolder.reactions = view.findViewById(R.id.reactions);
1183 break;
1184 case STATUS:
1185 view =
1186 activity.getLayoutInflater()
1187 .inflate(R.layout.item_message_status, parent, false);
1188 viewHolder.contact_picture = view.findViewById(R.id.message_photo);
1189 viewHolder.status_message = view.findViewById(R.id.status_message);
1190 viewHolder.load_more_messages = view.findViewById(R.id.load_more_messages);
1191 break;
1192 default:
1193 throw new AssertionError("Unknown view type");
1194 }
1195 if (viewHolder.link_descriptions != null) {
1196 viewHolder.link_descriptions.setOnItemClickListener((adapter, v, pos, id) -> {
1197 final var desc = (Element) adapter.getItemAtPosition(pos);
1198 var url = desc.findChildContent("url", "https://ogp.me/ns#");
1199 // should we prefer about? Maybe, it's the real original link, but it's not what we show the user
1200 if (url == null || url.length() < 1) url = desc.getAttribute("{http://www.w3.org/1999/02/22-rdf-syntax-ns#}about");
1201 if (url == null || url.length() < 1) return;
1202 new FixedURLSpan(url).onClick(v);
1203 });
1204 }
1205 view.setTag(viewHolder);
1206 } else {
1207 viewHolder = (ViewHolder) view.getTag();
1208 if (viewHolder == null) {
1209 return view;
1210 }
1211 }
1212
1213 if (viewHolder.messageBody != null) {
1214 viewHolder.messageBody.setCustomSelectionActionModeCallback(new MessageTextActionModeCallback(this, viewHolder.messageBody));
1215 }
1216
1217 if (viewHolder.time != null) {
1218 if (message.isAttention()) {
1219 viewHolder.time.setTypeface(null, Typeface.BOLD);
1220 } else {
1221 viewHolder.time.setTypeface(null, Typeface.NORMAL);
1222 }
1223 }
1224
1225 final var black = MaterialColors.getColor(view, com.google.android.material.R.attr.colorSecondaryContainer) == view.getContext().getColor(android.R.color.black);
1226 final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
1227 final BubbleColor bubbleColor;
1228 if (type == RECEIVED) {
1229 if (isInValidSession) {
1230 bubbleColor = colorfulBackground || black ? BubbleColor.SECONDARY : BubbleColor.SURFACE;
1231 } else {
1232 bubbleColor = BubbleColor.WARNING;
1233 }
1234 } else {
1235 if (!colorfulBackground && black) {
1236 bubbleColor = BubbleColor.SECONDARY;
1237 } else {
1238 bubbleColor = colorfulBackground ? BubbleColor.TERTIARY : BubbleColor.SURFACE_HIGH;
1239 }
1240 }
1241
1242 if (viewHolder.thread_identicon != null) {
1243 viewHolder.thread_identicon.setVisibility(View.GONE);
1244 final Element thread = message.getThread();
1245 if (thread != null) {
1246 final String threadId = thread.getContent();
1247 if (threadId != null) {
1248 final var roles = MaterialColors.getColorRoles(activity, UIHelper.getColorForName(threadId));
1249 viewHolder.thread_identicon.setVisibility(View.VISIBLE);
1250 viewHolder.thread_identicon.setColor(roles.getAccent());
1251 viewHolder.thread_identicon.setHash(UIHelper.identiconHash(threadId));
1252 }
1253 }
1254 }
1255
1256 if (type == DATE_SEPARATOR) {
1257 if (UIHelper.today(message.getTimeSent())) {
1258 viewHolder.status_message.setText(R.string.today);
1259 } else if (UIHelper.yesterday(message.getTimeSent())) {
1260 viewHolder.status_message.setText(R.string.yesterday);
1261 } else {
1262 viewHolder.status_message.setText(
1263 DateUtils.formatDateTime(
1264 activity,
1265 message.getTimeSent(),
1266 DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR));
1267 }
1268 if (colorfulBackground) {
1269 setBackgroundTint(viewHolder.message_box, BubbleColor.PRIMARY);
1270 setTextColor(viewHolder.status_message, BubbleColor.PRIMARY);
1271 } else {
1272 setBackgroundTint(viewHolder.message_box, BubbleColor.SURFACE_HIGH);
1273 setTextColor(viewHolder.status_message, BubbleColor.SURFACE_HIGH);
1274 }
1275 return view;
1276 } else if (type == RTP_SESSION) {
1277 final boolean received = message.getStatus() <= Message.STATUS_RECEIVED;
1278 final RtpSessionStatus rtpSessionStatus = RtpSessionStatus.of(message.getBody());
1279 final long duration = rtpSessionStatus.duration;
1280 final String callTime = UIHelper.readableTimeDifferenceFull(activity, message.getTimeSent());
1281 if (received) {
1282 if (duration > 0) {
1283 viewHolder.status_message.setText(
1284 activity.getString(
1285 R.string.incoming_call_duration_timestamp,
1286 TimeFrameUtils.resolve(activity, duration),
1287 UIHelper.readableTimeDifferenceFull(
1288 activity, message.getTimeSent())));
1289 } else if (rtpSessionStatus.successful) {
1290 viewHolder.status_message.setText(activity.getString(R.string.incoming_call_timestamp, callTime));
1291 } else {
1292 viewHolder.status_message.setText(
1293 activity.getString(
1294 R.string.missed_call_timestamp,
1295 UIHelper.readableTimeDifferenceFull(
1296 activity, message.getTimeSent())));
1297 }
1298 } else {
1299 if (duration > 0) {
1300 viewHolder.status_message.setText(
1301 activity.getString(
1302 R.string.outgoing_call_duration_timestamp,
1303 TimeFrameUtils.resolve(activity, duration),
1304 UIHelper.readableTimeDifferenceFull(
1305 activity, message.getTimeSent())));
1306 } else {
1307 viewHolder.status_message.setText(
1308 activity.getString(
1309 R.string.outgoing_call_timestamp,
1310 UIHelper.readableTimeDifferenceFull(
1311 activity, message.getTimeSent())));
1312 }
1313 }
1314 if (colorfulBackground) {
1315 setBackgroundTint(viewHolder.message_box, BubbleColor.SECONDARY);
1316 setTextColor(viewHolder.status_message, BubbleColor.SECONDARY);
1317 setImageTint(viewHolder.indicatorReceived, BubbleColor.SECONDARY);
1318 } else {
1319 setBackgroundTint(viewHolder.message_box, BubbleColor.SURFACE_HIGH);
1320 setTextColor(viewHolder.status_message, BubbleColor.SURFACE_HIGH);
1321 setImageTint(viewHolder.indicatorReceived, BubbleColor.SURFACE_HIGH);
1322 }
1323 viewHolder.indicatorReceived.setImageResource(
1324 RtpSessionStatus.getDrawable(received, rtpSessionStatus.successful));
1325 return view;
1326 } else if (type == STATUS) {
1327 if ("LOAD_MORE".equals(message.getBody())) {
1328 viewHolder.status_message.setVisibility(View.GONE);
1329 viewHolder.contact_picture.setVisibility(View.GONE);
1330 viewHolder.load_more_messages.setVisibility(View.VISIBLE);
1331 viewHolder.load_more_messages.setOnClickListener(
1332 v -> loadMoreMessages((Conversation) message.getConversation()));
1333 } else {
1334 viewHolder.status_message.setVisibility(View.VISIBLE);
1335 viewHolder.load_more_messages.setVisibility(View.GONE);
1336 viewHolder.status_message.setText(message.getBody());
1337 boolean showAvatar;
1338 if (conversation.getMode() == Conversation.MODE_SINGLE) {
1339 showAvatar = true;
1340 AvatarWorkerTask.loadAvatar(
1341 message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
1342 } else if (message.getCounterpart() != null
1343 || message.getTrueCounterpart() != null
1344 || (message.getCounterparts() != null
1345 && message.getCounterparts().size() > 0)) {
1346 showAvatar = true;
1347 AvatarWorkerTask.loadAvatar(
1348 message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
1349 } else {
1350 showAvatar = false;
1351 }
1352 if (showAvatar) {
1353 viewHolder.contact_picture.setAlpha(0.5f);
1354 viewHolder.contact_picture.setVisibility(View.VISIBLE);
1355 } else {
1356 viewHolder.contact_picture.setVisibility(View.GONE);
1357 }
1358 }
1359 return view;
1360 } else {
1361 // viewHolder.message_box.setClipToOutline(true); This eats the bubble tails on A14 for some reason
1362 AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar_on_conversation_overview);
1363 }
1364
1365 resetClickListener(viewHolder.message_box, viewHolder.messageBody);
1366
1367 viewHolder.message_box.setOnClickListener(v -> {
1368 if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1369 MessageAdapter.this.mOnMessageBoxClickedListener
1370 .onContactPictureClicked(message);
1371 }
1372 });
1373 SwipeDetector swipeDetector = new SwipeDetector((action) -> {
1374 if (action == SwipeDetector.Action.LR && MessageAdapter.this.mOnMessageBoxSwipedListener != null) {
1375 MessageAdapter.this.mOnMessageBoxSwipedListener.onContactPictureClicked(message);
1376 }
1377 });
1378 viewHolder.message_box.setOnTouchListener(swipeDetector);
1379 viewHolder.image.setOnTouchListener(swipeDetector);
1380 viewHolder.time.setOnTouchListener(swipeDetector);
1381
1382 // Treat touch-up as click so we don't have to touch twice
1383 // (touch twice is because it's waiting to see if you double-touch for text selection)
1384 viewHolder.messageBody.setOnTouchListener((v, event) -> {
1385 if (event.getAction() == MotionEvent.ACTION_UP) {
1386 if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1387 MessageAdapter.this.mOnMessageBoxClickedListener
1388 .onContactPictureClicked(message);
1389 }
1390 }
1391
1392 swipeDetector.onTouch(v, event);
1393
1394 return false;
1395 });
1396 viewHolder.messageBody.setOnClickListener(v -> {
1397 if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1398 MessageAdapter.this.mOnMessageBoxClickedListener
1399 .onContactPictureClicked(message);
1400 }
1401 });
1402 viewHolder.contact_picture.setOnClickListener(v -> {
1403 if (MessageAdapter.this.mOnContactPictureClickedListener != null) {
1404 MessageAdapter.this.mOnContactPictureClickedListener
1405 .onContactPictureClicked(message);
1406 }
1407
1408 });
1409 viewHolder.contact_picture.setOnLongClickListener(v -> {
1410 if (MessageAdapter.this.mOnContactPictureLongClickedListener != null) {
1411 MessageAdapter.this.mOnContactPictureLongClickedListener
1412 .onContactPictureLongClicked(v, message);
1413 return true;
1414 } else {
1415 return false;
1416 }
1417 });
1418 viewHolder.messageBody.setAccessibilityDelegate(null);
1419
1420 boolean footerWrap = false;
1421
1422 final Transferable transferable = message.getTransferable();
1423 final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
1424
1425 final boolean muted = message.getStatus() == Message.STATUS_RECEIVED && conversation.getMode() == Conversation.MODE_MULTI && activity.xmppConnectionService.isMucUserMuted(new MucOptions.User(null, conversation.getJid(), message.getOccupantId(), null, null));
1426 if (muted) {
1427 // Muted MUC participant
1428 displayInfoMessage(viewHolder, "Muted", bubbleColor);
1429 } else if (unInitiatedButKnownSize || message.isDeleted() || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING)) {
1430 if (unInitiatedButKnownSize || (message.isDeleted() && message.getModerated() == null) || transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER) {
1431 displayDownloadableMessage(viewHolder, message, activity.getString(R.string.download_x_file, UIHelper.getFileDescriptionString(activity, message)), bubbleColor, type);
1432 } else if (transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER_CHECK_FILESIZE) {
1433 displayDownloadableMessage(viewHolder, message, activity.getString(R.string.check_x_filesize, UIHelper.getFileDescriptionString(activity, message)), bubbleColor, type);
1434 } else {
1435 displayInfoMessage(viewHolder, UIHelper.getMessagePreview(activity.xmppConnectionService, message).first, bubbleColor);
1436 }
1437 } else if (message.isFileOrImage()
1438 && message.getEncryption() != Message.ENCRYPTION_PGP
1439 && message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED) {
1440 if (message.getFileParams().width > 0 && message.getFileParams().height > 0) {
1441 displayMediaPreviewMessage(viewHolder, message, bubbleColor, type);
1442 if (!black && viewHolder.image.getLayoutParams().width > metrics.density * 110) {
1443 footerWrap = true;
1444 }
1445 } else if (message.getFileParams().runtime > 0) {
1446 displayAudioMessage(viewHolder, message, bubbleColor, type);
1447 } else if ("application/webxdc+zip".equals(message.getFileParams().getMediaType()) && message.getConversation() instanceof Conversation && message.getThread() != null && !message.getFileParams().getCids().isEmpty()) {
1448 displayWebxdcMessage(viewHolder, message, bubbleColor, type);
1449 } else {
1450 displayOpenableMessage(viewHolder, message, bubbleColor, type);
1451 }
1452 } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
1453 if (account.isPgpDecryptionServiceConnected()) {
1454 if (conversation instanceof Conversation
1455 && !account.hasPendingPgpIntent((Conversation) conversation)) {
1456 displayInfoMessage(
1457 viewHolder,
1458 activity.getString(R.string.message_decrypting),
1459 bubbleColor);
1460 } else {
1461 displayInfoMessage(
1462 viewHolder, activity.getString(R.string.pgp_message), bubbleColor);
1463 }
1464 } else {
1465 displayInfoMessage(
1466 viewHolder, activity.getString(R.string.install_openkeychain), bubbleColor);
1467 viewHolder.message_box.setOnClickListener(this::promptOpenKeychainInstall);
1468 viewHolder.messageBody.setOnClickListener(this::promptOpenKeychainInstall);
1469 }
1470 } else if (message.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) {
1471 displayInfoMessage(
1472 viewHolder, activity.getString(R.string.decryption_failed), bubbleColor);
1473 } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE) {
1474 displayInfoMessage(
1475 viewHolder,
1476 activity.getString(R.string.not_encrypted_for_this_device),
1477 bubbleColor);
1478 } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_FAILED) {
1479 displayInfoMessage(
1480 viewHolder, activity.getString(R.string.omemo_decryption_failed), bubbleColor);
1481 } else {
1482 if (message.wholeIsKnownURI() != null) {
1483 displayURIMessage(viewHolder, message, bubbleColor, type);
1484 } else if (message.isGeoUri()) {
1485 displayLocationMessage(viewHolder, message, bubbleColor, type);
1486 } else if (message.treatAsDownloadable()) {
1487 try {
1488 final URI uri = message.getOob();
1489 displayDownloadableMessage(viewHolder,
1490 message,
1491 activity.getString(
1492 R.string.check_x_filesize_on_host,
1493 UIHelper.getFileDescriptionString(activity, message),
1494 uri.getHost()),
1495 bubbleColor, type);
1496 } catch (Exception e) {
1497 displayDownloadableMessage(
1498 viewHolder,
1499 message,
1500 activity.getString(
1501 R.string.check_x_filesize,
1502 UIHelper.getFileDescriptionString(activity, message)),
1503 bubbleColor, type);
1504 }
1505 } else if (message.bodyIsOnlyEmojis() && message.getType() != Message.TYPE_PRIVATE) {
1506 displayEmojiMessage(viewHolder, message, bubbleColor, type);
1507 } else {
1508 displayTextMessage(viewHolder, message, bubbleColor, message.getType());
1509 }
1510 }
1511
1512 viewHolder.message_box_inner.setMinimumWidth(footerWrap ? (int) (110 * metrics.density) : 0);
1513 LinearLayout.LayoutParams statusParams = (LinearLayout.LayoutParams) viewHolder.status_line.getLayoutParams();
1514 statusParams.width = footerWrap ? ViewGroup.LayoutParams.MATCH_PARENT : ViewGroup.LayoutParams.WRAP_CONTENT;
1515 viewHolder.status_line.setLayoutParams(statusParams);
1516
1517 setBackgroundTint(viewHolder.message_box, bubbleColor);
1518 setTextColor(viewHolder.messageBody, bubbleColor);
1519 viewHolder.messageBody.setLinkTextColor(bubbleToOnSurfaceColor(viewHolder.messageBody, bubbleColor));
1520
1521 final Function<Reaction, GetThumbnailForCid> reactionThumbnailer = (r) -> new Thumbnailer(conversation.getAccount(), r, conversation.canInferPresence());
1522 if (type == RECEIVED) {
1523 if (!muted && commands != null && conversation instanceof Conversation) {
1524 CommandButtonAdapter adapter = new CommandButtonAdapter(activity);
1525 adapter.addAll(commands);
1526 viewHolder.commands_list.setAdapter(adapter);
1527 viewHolder.commands_list.setVisibility(View.VISIBLE);
1528 viewHolder.commands_list.setOnItemClickListener((p, v, pos, id) -> {
1529 final Element command = adapter.getItem(pos);
1530 activity.startCommand(conversation.getAccount(), command.getAttributeAsJid("jid"), command.getAttribute("node"));
1531 });
1532 } else {
1533 // It's unclear if we can set this to null...
1534 ListAdapter adapter = viewHolder.commands_list.getAdapter();
1535 if (adapter instanceof ArrayAdapter) {
1536 ((ArrayAdapter<?>) adapter).clear();
1537 }
1538 viewHolder.commands_list.setVisibility(View.GONE);
1539 viewHolder.commands_list.setOnItemClickListener(null);
1540 }
1541
1542 setTextColor(viewHolder.encryption, bubbleColor);
1543
1544 if (isInValidSession) {
1545 viewHolder.encryption.setVisibility(View.GONE);
1546 } else {
1547 viewHolder.encryption.setVisibility(View.VISIBLE);
1548 if (omemoEncryption && !message.isTrusted()) {
1549 viewHolder.encryption.setText(R.string.not_trusted);
1550 } else {
1551 viewHolder.encryption.setText(
1552 CryptoHelper.encryptionTypeToText(message.getEncryption()));
1553 }
1554 }
1555 final var aggregatedReactions = conversation instanceof Conversation ? ((Conversation) conversation).aggregatedReactionsFor(message, reactionThumbnailer) : message.getAggregatedReactions();
1556 BindingAdapters.setReactionsOnReceived(
1557 viewHolder.reactions,
1558 conversation instanceof Conversation ? (Conversation) conversation : null,
1559 aggregatedReactions,
1560 reactions -> sendReactions(message, reactions),
1561 emoji -> sendCustomReaction(message, emoji),
1562 reaction -> removeCustomReaction(conversation, reaction),
1563 () -> addReaction(message));
1564 } else if (type == SENT) {
1565 final var aggregatedReactions = conversation instanceof Conversation ? ((Conversation) conversation).aggregatedReactionsFor(message, reactionThumbnailer) : message.getAggregatedReactions();
1566 BindingAdapters.setReactionsOnReceived(
1567 viewHolder.reactions,
1568 conversation instanceof Conversation ? (Conversation) conversation : null,
1569 aggregatedReactions,
1570 reactions -> sendReactions(message, reactions),
1571 emoji -> sendCustomReaction(message, emoji),
1572 reaction -> removeCustomReaction(conversation, reaction),
1573 () -> addReaction(message));
1574 }
1575
1576 if (type == RECEIVED || type == SENT) {
1577 String subject = message.getSubject();
1578 if (subject == null && message.getThread() != null) {
1579 final var thread = ((Conversation) message.getConversation()).getThread(message.getThread().getContent());
1580 if (thread != null) subject = thread.getSubject();
1581 }
1582 if (muted || subject == null) {
1583 viewHolder.subject.setVisibility(View.GONE);
1584 } else {
1585 viewHolder.subject.setVisibility(View.VISIBLE);
1586 viewHolder.subject.setText(subject);
1587 }
1588
1589 if (message.getInReplyTo() == null) {
1590 viewHolder.inReplyToBox.setVisibility(View.GONE);
1591 } else {
1592 viewHolder.inReplyToBox.setVisibility(View.VISIBLE);
1593 viewHolder.inReplyTo.setText(UIHelper.getMessageDisplayName(message.getInReplyTo()));
1594 viewHolder.inReplyTo.setOnClickListener((v) -> mConversationFragment.jumpTo(message.getInReplyTo()));
1595 viewHolder.inReplyToQuote.setOnClickListener((v) -> mConversationFragment.jumpTo(message.getInReplyTo()));
1596 setTextColor(viewHolder.inReplyTo, bubbleColor);
1597 }
1598
1599 if (appSettings.showLinkPreviews()) {
1600 final var descriptions = message.getLinkDescriptions();
1601 viewHolder.link_descriptions.setAdapter(new ArrayAdapter<>(activity, 0, descriptions) {
1602 @Override
1603 public View getView(int position, View view, @NonNull ViewGroup parent) {
1604 final LinkDescriptionBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()), R.layout.link_description, parent, false);
1605 binding.title.setText(getItem(position).findChildContent("title", "https://ogp.me/ns#"));
1606 binding.description.setText(getItem(position).findChildContent("description", "https://ogp.me/ns#"));
1607 binding.url.setText(getItem(position).findChildContent("url", "https://ogp.me/ns#"));
1608 final var video = getItem(position).findChildContent("video", "https://ogp.me/ns#");
1609 if (video != null && video.length() > 0) {
1610 binding.playButton.setVisibility(View.VISIBLE);
1611 binding.playButton.setOnClickListener((v) -> {
1612 new FixedURLSpan(video).onClick(v);
1613 });
1614 }
1615 return binding.getRoot();
1616 }
1617 });
1618 Util.justifyListViewHeightBasedOnChildren(viewHolder.link_descriptions, (int)(metrics.density * 100), true);
1619 }
1620 }
1621
1622 displayStatus(viewHolder, message, type, bubbleColor);
1623
1624 viewHolder.messageBody.setAccessibilityDelegate(new View.AccessibilityDelegate() {
1625 @Override
1626 public void sendAccessibilityEvent(View host, int eventType) {
1627 super.sendAccessibilityEvent(host, eventType);
1628 if (eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) {
1629 if (viewHolder.messageBody.hasSelection()) {
1630 selectionUuid = message.getUuid();
1631 } else if (message.getUuid() != null && message.getUuid().equals(selectionUuid)) {
1632 selectionUuid = null;
1633 }
1634 }
1635 }
1636 });
1637
1638 return view;
1639 }
1640
1641 private void sendReactions(final Message message, final Collection<String> reactions) {
1642 if (activity.xmppConnectionService.sendReactions(message, reactions)) {
1643 return;
1644 }
1645 Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
1646 }
1647
1648 private void sendCustomReaction(final Message inReplyTo, final EmojiSearch.CustomEmoji emoji) {
1649 final var message = inReplyTo.reply();
1650 message.appendBody(emoji.toInsert());
1651 Message.configurePrivateMessage(message);
1652 new Thread(() -> activity.xmppConnectionService.sendMessage(message)).start();
1653 }
1654
1655 private void removeCustomReaction(final Conversational conversation, final Reaction reaction) {
1656 if (!(conversation instanceof Conversation)) {
1657 Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
1658 return;
1659 }
1660
1661 final var message = new Message(conversation, " ", ((Conversation) conversation).getNextEncryption());
1662 final var envelope = ((Conversation) conversation).findMessageWithUuidOrRemoteId(reaction.envelopeId);
1663 if (envelope != null) {
1664 ((Conversation) conversation).remove(envelope);
1665 message.addPayload(envelope.getReply());
1666 message.getOrMakeHtml();
1667 message.putEdited(reaction.envelopeId, envelope.getServerMsgId());
1668 } else {
1669 message.putEdited(reaction.envelopeId, null);
1670 }
1671
1672 new Thread(() -> activity.xmppConnectionService.sendMessage(message)).start();
1673 }
1674
1675 private void addReaction(final Message message) {
1676 activity.addReaction(message, reactions -> activity.xmppConnectionService.sendReactions(message,reactions));
1677 }
1678
1679 private void promptOpenKeychainInstall(View view) {
1680 activity.showInstallPgpDialog();
1681 }
1682
1683 public FileBackend getFileBackend() {
1684 return activity.xmppConnectionService.getFileBackend();
1685 }
1686
1687 public void stopAudioPlayer() {
1688 audioPlayer.stop();
1689 }
1690
1691 public void unregisterListenerInAudioPlayer() {
1692 audioPlayer.unregisterListener();
1693 }
1694
1695 public void startStopPending() {
1696 audioPlayer.startStopPending();
1697 }
1698
1699 public void openDownloadable(Message message) {
1700 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
1701 && ContextCompat.checkSelfPermission(
1702 activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)
1703 != PackageManager.PERMISSION_GRANTED) {
1704 ConversationFragment.registerPendingMessage(activity, message);
1705 ActivityCompat.requestPermissions(
1706 activity,
1707 new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE},
1708 ConversationsActivity.REQUEST_OPEN_MESSAGE);
1709 return;
1710 }
1711 final DownloadableFile file =
1712 activity.xmppConnectionService.getFileBackend().getFile(message);
1713 ViewUtil.view(activity, file);
1714 }
1715
1716 private void showLocation(Message message) {
1717 for (Intent intent : GeoHelper.createGeoIntentsFromMessage(activity, message)) {
1718 if (intent.resolveActivity(getContext().getPackageManager()) != null) {
1719 getContext().startActivity(intent);
1720 return;
1721 }
1722 }
1723 Toast.makeText(
1724 activity,
1725 R.string.no_application_found_to_display_location,
1726 Toast.LENGTH_SHORT)
1727 .show();
1728 }
1729
1730 public void updatePreferences() {
1731 this.bubbleDesign =
1732 new BubbleDesign(appSettings.isColorfulChatBubbles(), appSettings.isLargeFont());
1733 }
1734
1735 public void setHighlightedTerm(List<String> terms) {
1736 this.highlightedTerm = terms == null ? null : StylingHelper.filterHighlightedWords(terms);
1737 }
1738
1739 public interface OnContactPictureClicked {
1740 void onContactPictureClicked(Message message);
1741 }
1742
1743 public interface OnContactPictureLongClicked {
1744 void onContactPictureLongClicked(View v, Message message);
1745 }
1746
1747 public interface OnInlineImageLongClicked {
1748 boolean onInlineImageLongClicked(Cid cid);
1749 }
1750
1751 private static void setBackgroundTint(final View view, final BubbleColor bubbleColor) {
1752 view.setBackgroundTintList(bubbleToColorStateList(view, bubbleColor));
1753 }
1754
1755 private static ColorStateList bubbleToColorStateList(
1756 final View view, final BubbleColor bubbleColor) {
1757 final @AttrRes int colorAttributeResId =
1758 switch (bubbleColor) {
1759 case SURFACE -> Activities.isNightMode(view.getContext())
1760 ? com.google.android.material.R.attr.colorSurfaceContainerHigh
1761 : com.google.android.material.R.attr.colorSurfaceContainerLow;
1762 case SURFACE_HIGH -> Activities.isNightMode(view.getContext())
1763 ? com.google.android.material.R.attr.colorSurfaceContainerHighest
1764 : com.google.android.material.R.attr.colorSurfaceContainerHigh;
1765 case PRIMARY -> com.google.android.material.R.attr.colorPrimaryContainer;
1766 case SECONDARY -> com.google.android.material.R.attr.colorSecondaryContainer;
1767 case TERTIARY -> com.google.android.material.R.attr.colorTertiaryContainer;
1768 case WARNING -> com.google.android.material.R.attr.colorErrorContainer;
1769 };
1770 return ColorStateList.valueOf(MaterialColors.getColor(view, colorAttributeResId));
1771 }
1772
1773 public static void setImageTint(final ImageView imageView, final BubbleColor bubbleColor) {
1774 ImageViewCompat.setImageTintList(
1775 imageView, bubbleToOnSurfaceColorStateList(imageView, bubbleColor));
1776 }
1777
1778 public static void setImageTintError(final ImageView imageView) {
1779 ImageViewCompat.setImageTintList(
1780 imageView,
1781 ColorStateList.valueOf(
1782 MaterialColors.getColor(
1783 imageView, com.google.android.material.R.attr.colorError)));
1784 }
1785
1786 public static void setTextColor(final TextView textView, final BubbleColor bubbleColor) {
1787 final var color = bubbleToOnSurfaceColor(textView, bubbleColor);
1788 textView.setTextColor(color);
1789 if (BubbleColor.SURFACES.contains(bubbleColor)) {
1790 textView.setLinkTextColor(
1791 MaterialColors.getColor(
1792 textView, com.google.android.material.R.attr.colorPrimary));
1793 } else {
1794 textView.setLinkTextColor(color);
1795 }
1796 }
1797
1798 private static void setTextSize(final TextView textView, final boolean largeFont) {
1799 if (largeFont) {
1800 textView.setTextAppearance(
1801 com.google.android.material.R.style.TextAppearance_Material3_BodyLarge);
1802 textView.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 18);
1803 } else {
1804 textView.setTextAppearance(
1805 com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
1806 }
1807 }
1808
1809 private static @ColorInt int bubbleToOnSurfaceVariant(
1810 final View view, final BubbleColor bubbleColor) {
1811 final @AttrRes int colorAttributeResId;
1812 if (BubbleColor.SURFACES.contains(bubbleColor)) {
1813 colorAttributeResId = com.google.android.material.R.attr.colorOnSurfaceVariant;
1814 } else {
1815 colorAttributeResId = bubbleToOnSurface(bubbleColor);
1816 }
1817 return MaterialColors.getColor(view, colorAttributeResId);
1818 }
1819
1820 private static @ColorInt int bubbleToOnSurfaceColor(
1821 final View view, final BubbleColor bubbleColor) {
1822 return MaterialColors.getColor(view, bubbleToOnSurface(bubbleColor));
1823 }
1824
1825 public static ColorStateList bubbleToOnSurfaceColorStateList(
1826 final View view, final BubbleColor bubbleColor) {
1827 return ColorStateList.valueOf(bubbleToOnSurfaceColor(view, bubbleColor));
1828 }
1829
1830 private static @AttrRes int bubbleToOnSurface(final BubbleColor bubbleColor) {
1831 return switch (bubbleColor) {
1832 case SURFACE, SURFACE_HIGH -> com.google.android.material.R.attr.colorOnSurface;
1833 case PRIMARY -> com.google.android.material.R.attr.colorOnPrimaryContainer;
1834 case SECONDARY -> com.google.android.material.R.attr.colorOnSecondaryContainer;
1835 case TERTIARY -> com.google.android.material.R.attr.colorOnTertiaryContainer;
1836 case WARNING -> com.google.android.material.R.attr.colorOnErrorContainer;
1837 };
1838 }
1839
1840 public enum BubbleColor {
1841 SURFACE,
1842 SURFACE_HIGH,
1843 PRIMARY,
1844 SECONDARY,
1845 TERTIARY,
1846 WARNING;
1847
1848 private static final Collection<BubbleColor> SURFACES =
1849 Arrays.asList(BubbleColor.SURFACE, BubbleColor.SURFACE_HIGH);
1850 }
1851
1852 private static class BubbleDesign {
1853 public final boolean colorfulChatBubbles;
1854 public final boolean largeFont;
1855
1856 private BubbleDesign(final boolean colorfulChatBubbles, final boolean largeFont) {
1857 this.colorfulChatBubbles = colorfulChatBubbles;
1858 this.largeFont = largeFont;
1859 }
1860 }
1861
1862 private static class ViewHolder {
1863
1864 public MaterialButton load_more_messages;
1865 public ImageView edit_indicator;
1866 public RelativeLayout audioPlayer;
1867 protected View status_line;
1868 protected LinearLayout message_box;
1869 protected View message_box_inner;
1870 protected MaterialButton download_button;
1871 protected ShapeableImageView image;
1872 protected ImageView indicator;
1873 protected ImageView indicatorReceived;
1874 protected TextView time;
1875 protected TextView subject;
1876 protected TextView inReplyTo;
1877 protected TextView inReplyToQuote;
1878 protected LinearLayout inReplyToBox;
1879 protected TextView messageBody;
1880 protected ImageView contact_picture;
1881 protected TextView status_message;
1882 protected TextView encryption;
1883 protected ListView commands_list;
1884 protected ListView link_descriptions;
1885 protected GithubIdenticonView thread_identicon;
1886 protected ChipGroup reactions;
1887 }
1888
1889 class Thumbnailer implements GetThumbnailForCid {
1890 final Account account;
1891 final boolean canFetch;
1892 final Jid counterpart;
1893
1894 public Thumbnailer(final Message message) {
1895 account = message.getConversation().getAccount();
1896 canFetch = message.trusted() || message.getConversation().canInferPresence();
1897 counterpart = message.getCounterpart();
1898 }
1899
1900 public Thumbnailer(final Account account, final Reaction reaction, final boolean allowFetch) {
1901 canFetch = allowFetch;
1902 counterpart = reaction.from;
1903 this.account = account;
1904 }
1905
1906 @Override
1907 public Drawable getThumbnail(Cid cid) {
1908 try {
1909 DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
1910 if (f == null || !f.canRead()) {
1911 if (!canFetch) return null;
1912
1913 try {
1914 new BobTransfer(BobTransfer.uri(cid), account, counterpart, activity.xmppConnectionService).start();
1915 } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
1916 return null;
1917 }
1918
1919 Drawable d = activity.xmppConnectionService.getFileBackend().getThumbnail(f, activity.getResources(), (int) (metrics.density * 288), true);
1920 if (d == null) {
1921 new ThumbnailTask().execute(f);
1922 }
1923 return d;
1924 } catch (final IOException e) {
1925 return null;
1926 }
1927 }
1928 }
1929
1930 class ThumbnailTask extends AsyncTask<DownloadableFile, Void, Drawable[]> {
1931 @Override
1932 protected Drawable[] doInBackground(DownloadableFile... params) {
1933 if (isCancelled()) return null;
1934
1935 Drawable[] d = new Drawable[params.length];
1936 for (int i = 0; i < params.length; i++) {
1937 try {
1938 d[i] = activity.xmppConnectionService.getFileBackend().getThumbnail(params[i], activity.getResources(), (int) (metrics.density * 288), false);
1939 } catch (final IOException e) {
1940 d[i] = null;
1941 }
1942 }
1943
1944 return d;
1945 }
1946
1947 @Override
1948 protected void onPostExecute(final Drawable[] d) {
1949 if (isCancelled()) return;
1950 activity.xmppConnectionService.updateConversationUi();
1951 }
1952 }
1953}