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 final var amount = uri.getQueryParameter("amount");
937 final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
938 if ("bitcoin".equals(uri.getScheme())) {
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 viewHolder.download_button.setIconResource(R.drawable.bitcoin_cash_24dp);
943 viewHolder.download_button.setText("Send " + formattedAmount + "Bitcoin Cash");
944 } else if ("monero".equals(uri.getScheme())) {
945 viewHolder.download_button.setIconResource(R.drawable.monero_24dp);
946 viewHolder.download_button.setText("Send " + formattedAmount + "Monero");
947 }
948 viewHolder.download_button.setOnClickListener(v -> new FixedURLSpan(message.getRawBody()).onClick(v));
949 }
950
951 private void displayLocationMessage(
952 ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
953 displayTextMessage(viewHolder, message, bubbleColor, type);
954 viewHolder.messageBody.setVisibility(View.GONE);
955 viewHolder.image.setVisibility(View.GONE);
956 viewHolder.audioPlayer.setVisibility(View.GONE);
957 viewHolder.download_button.setVisibility(View.VISIBLE);
958 viewHolder.download_button.setText(R.string.show_location);
959 final var attachment = Attachment.of(message);
960 final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
961 viewHolder.download_button.setIconResource(imageResource);
962 viewHolder.download_button.setOnClickListener(v -> showLocation(message));
963 }
964
965 private void displayAudioMessage(
966 ViewHolder viewHolder, Message message, final BubbleColor bubbleColor, final int type) {
967 displayTextMessage(viewHolder, message, bubbleColor, type);
968 viewHolder.image.setVisibility(View.GONE);
969 viewHolder.download_button.setVisibility(View.GONE);
970 final RelativeLayout audioPlayer = viewHolder.audioPlayer;
971 audioPlayer.setVisibility(View.VISIBLE);
972 AudioPlayer.ViewHolder.get(audioPlayer).setBubbleColor(bubbleColor);
973 this.audioPlayer.init(audioPlayer, message);
974 }
975
976 private void displayMediaPreviewMessage(
977 ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
978 displayTextMessage(viewHolder, message, bubbleColor, type);
979 viewHolder.download_button.setVisibility(View.GONE);
980 viewHolder.audioPlayer.setVisibility(View.GONE);
981 viewHolder.image.setVisibility(View.VISIBLE);
982 final FileParams params = message.getFileParams();
983 imagePreviewLayout(params.width, params.height, viewHolder.image, message.getInReplyTo() != null, viewHolder.messageBody.getVisibility() != View.GONE, type, viewHolder);
984 activity.loadBitmap(message, viewHolder.image);
985 viewHolder.image.setOnClickListener(v -> openDownloadable(message));
986 }
987
988 private void imagePreviewLayout(int w, int h, ShapeableImageView image, boolean otherAbove, boolean otherBelow, int type, ViewHolder viewHolder) {
989 final float target = activity.getResources().getDimension(R.dimen.image_preview_width);
990 final int scaledW;
991 final int scaledH;
992 if (Math.max(h, w) * metrics.density <= target) {
993 scaledW = (int) (w * metrics.density);
994 scaledH = (int) (h * metrics.density);
995 } else if (Math.max(h, w) <= target) {
996 scaledW = w;
997 scaledH = h;
998 } else if (w <= h) {
999 scaledW = (int) (w / ((double) h / target));
1000 scaledH = (int) target;
1001 } else {
1002 scaledW = (int) target;
1003 scaledH = (int) (h / ((double) w / target));
1004 }
1005 final var bodyWidth = Math.max(viewHolder.messageBody.getWidth(), viewHolder.download_button.getWidth() + (20 * metrics.density));
1006 var targetImageWidth = 200 * metrics.density;
1007 if (!otherBelow) targetImageWidth = 110 * metrics.density;
1008 if (bodyWidth > 0 && bodyWidth < targetImageWidth) targetImageWidth = bodyWidth;
1009 final var small = scaledW < targetImageWidth;
1010 final LinearLayout.LayoutParams layoutParams =
1011 new LinearLayout.LayoutParams(scaledW, scaledH);
1012 image.setLayoutParams(layoutParams);
1013
1014 final var bubbleRadius = activity.getResources().getDimension(R.dimen.bubble_radius);
1015 var shape = new ShapeAppearanceModel.Builder();
1016 if (!otherAbove) {
1017 shape = shape.setTopRightCorner(CornerFamily.ROUNDED, bubbleRadius);
1018 if (type == SENT) {
1019 shape = shape.setTopLeftCorner(CornerFamily.ROUNDED, bubbleRadius);
1020 }
1021 }
1022 if (small) {
1023 final var imageRadius = activity.getResources().getDimension(R.dimen.image_radius);
1024 shape = shape.setAllCorners(CornerFamily.ROUNDED, imageRadius);
1025 image.setPadding(0, (int)(8 * metrics.density), 0, 0);
1026 } else {
1027 image.setPadding(0, 0, 0, 0);
1028 }
1029 image.setShapeAppearanceModel(shape.build());
1030
1031 if (!small) {
1032 final ViewGroup.LayoutParams blayoutParams = viewHolder.messageBody.getLayoutParams();
1033 blayoutParams.width = (int) (scaledW - (22 * metrics.density));
1034 viewHolder.messageBody.setLayoutParams(blayoutParams);
1035
1036 final ViewGroup.LayoutParams qlayoutParams = viewHolder.inReplyToQuote.getLayoutParams();
1037 qlayoutParams.width = (int) (scaledW - (22 * metrics.density));
1038 viewHolder.messageBody.setLayoutParams(qlayoutParams);
1039 }
1040 }
1041
1042 private void toggleWhisperInfo(
1043 ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor) {
1044 if (message.isPrivateMessage()) {
1045 final String privateMarker;
1046 if (message.getStatus() <= Message.STATUS_RECEIVED) {
1047 privateMarker = activity.getString(R.string.private_message);
1048 } else {
1049 Jid cp = message.getCounterpart();
1050 privateMarker =
1051 activity.getString(
1052 R.string.private_message_to,
1053 Strings.nullToEmpty(cp == null ? null : cp.getResource()));
1054 }
1055 final SpannableString body = new SpannableString(privateMarker);
1056 body.setSpan(
1057 new ForegroundColorSpan(
1058 bubbleToOnSurfaceVariant(viewHolder.messageBody, bubbleColor)),
1059 0,
1060 privateMarker.length(),
1061 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1062 body.setSpan(
1063 new StyleSpan(Typeface.BOLD),
1064 0,
1065 privateMarker.length(),
1066 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1067 viewHolder.messageBody.setText(body);
1068 viewHolder.messageBody.setVisibility(View.VISIBLE);
1069 } else {
1070 viewHolder.messageBody.setVisibility(View.GONE);
1071 }
1072 }
1073
1074 private void loadMoreMessages(Conversation conversation) {
1075 conversation.setLastClearHistory(0, null);
1076 activity.xmppConnectionService.updateConversation(conversation);
1077 conversation.setHasMessagesLeftOnServer(true);
1078 conversation.setFirstMamReference(null);
1079 long timestamp = conversation.getLastMessageTransmitted().getTimestamp();
1080 if (timestamp == 0) {
1081 timestamp = System.currentTimeMillis();
1082 }
1083 conversation.messagesLoaded.set(true);
1084 MessageArchiveService.Query query =
1085 activity.xmppConnectionService
1086 .getMessageArchiveService()
1087 .query(conversation, new MamReference(0), timestamp, false);
1088 if (query != null) {
1089 Toast.makeText(activity, R.string.fetching_history_from_server, Toast.LENGTH_LONG)
1090 .show();
1091 } else {
1092 Toast.makeText(
1093 activity,
1094 R.string.not_fetching_history_retention_period,
1095 Toast.LENGTH_SHORT)
1096 .show();
1097 }
1098 }
1099
1100 @Override
1101 public View getView(final int position, View view, final @NonNull ViewGroup parent) {
1102 final Message message = getItem(position);
1103 final boolean omemoEncryption = message.getEncryption() == Message.ENCRYPTION_AXOLOTL;
1104 final boolean isInValidSession =
1105 message.isValidInSession() && (!omemoEncryption || message.isTrusted());
1106 final Conversational conversation = message.getConversation();
1107 final Account account = conversation.getAccount();
1108 final List<Element> commands = message.getCommands();
1109 final int type = getItemViewType(position);
1110 ViewHolder viewHolder;
1111 if (view == null) {
1112 viewHolder = new ViewHolder();
1113 switch (type) {
1114 case DATE_SEPARATOR:
1115 view =
1116 activity.getLayoutInflater()
1117 .inflate(R.layout.item_message_date_bubble, parent, false);
1118 viewHolder.status_message = view.findViewById(R.id.message_body);
1119 viewHolder.message_box = view.findViewById(R.id.message_box);
1120 break;
1121 case RTP_SESSION:
1122 view =
1123 activity.getLayoutInflater()
1124 .inflate(R.layout.item_message_rtp_session, parent, false);
1125 viewHolder.status_message = view.findViewById(R.id.message_body);
1126 viewHolder.message_box = view.findViewById(R.id.message_box);
1127 viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
1128 break;
1129 case SENT:
1130 view = activity.getLayoutInflater().inflate(R.layout.item_message_sent, parent, false);
1131 viewHolder.status_line = view.findViewById(R.id.status_line);
1132 viewHolder.message_box_inner = view.findViewById(R.id.message_box_inner);
1133 viewHolder.message_box = view.findViewById(R.id.message_box);
1134 viewHolder.contact_picture = view.findViewById(R.id.message_photo);
1135 viewHolder.download_button = view.findViewById(R.id.download_button);
1136 viewHolder.indicator = view.findViewById(R.id.security_indicator);
1137 viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
1138 viewHolder.image = view.findViewById(R.id.message_image);
1139 viewHolder.messageBody = view.findViewById(R.id.message_body);
1140 viewHolder.time = view.findViewById(R.id.message_time);
1141 viewHolder.subject = view.findViewById(R.id.message_subject);
1142 viewHolder.inReplyTo = view.findViewById(R.id.in_reply_to);
1143 viewHolder.inReplyToBox = view.findViewById(R.id.in_reply_to_box);
1144 viewHolder.inReplyToQuote = view.findViewById(R.id.in_reply_to_quote);
1145 viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
1146 viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
1147 viewHolder.link_descriptions = view.findViewById(R.id.link_descriptions);
1148 viewHolder.thread_identicon = view.findViewById(R.id.thread_identicon);
1149 viewHolder.reactions = view.findViewById(R.id.reactions);
1150 break;
1151 case RECEIVED:
1152 view = activity.getLayoutInflater().inflate(R.layout.item_message_received, parent, false);
1153 viewHolder.status_line = view.findViewById(R.id.status_line);
1154 viewHolder.message_box_inner = view.findViewById(R.id.message_box_inner);
1155 viewHolder.message_box = view.findViewById(R.id.message_box);
1156 viewHolder.contact_picture = view.findViewById(R.id.message_photo);
1157 viewHolder.download_button = view.findViewById(R.id.download_button);
1158 viewHolder.indicator = view.findViewById(R.id.security_indicator);
1159 viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
1160 viewHolder.image = view.findViewById(R.id.message_image);
1161 viewHolder.messageBody = view.findViewById(R.id.message_body);
1162 viewHolder.time = view.findViewById(R.id.message_time);
1163 viewHolder.subject = view.findViewById(R.id.message_subject);
1164 viewHolder.inReplyTo = view.findViewById(R.id.in_reply_to);
1165 viewHolder.inReplyToQuote = view.findViewById(R.id.in_reply_to_quote);
1166 viewHolder.inReplyToBox = view.findViewById(R.id.in_reply_to_box);
1167 viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
1168 viewHolder.encryption = view.findViewById(R.id.message_encryption);
1169 viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
1170 viewHolder.commands_list = view.findViewById(R.id.commands_list);
1171 viewHolder.link_descriptions = view.findViewById(R.id.link_descriptions);
1172 viewHolder.thread_identicon = view.findViewById(R.id.thread_identicon);
1173 viewHolder.reactions = view.findViewById(R.id.reactions);
1174 break;
1175 case STATUS:
1176 view =
1177 activity.getLayoutInflater()
1178 .inflate(R.layout.item_message_status, parent, false);
1179 viewHolder.contact_picture = view.findViewById(R.id.message_photo);
1180 viewHolder.status_message = view.findViewById(R.id.status_message);
1181 viewHolder.load_more_messages = view.findViewById(R.id.load_more_messages);
1182 break;
1183 default:
1184 throw new AssertionError("Unknown view type");
1185 }
1186 if (viewHolder.link_descriptions != null) {
1187 viewHolder.link_descriptions.setOnItemClickListener((adapter, v, pos, id) -> {
1188 final var desc = (Element) adapter.getItemAtPosition(pos);
1189 var url = desc.findChildContent("url", "https://ogp.me/ns#");
1190 // should we prefer about? Maybe, it's the real original link, but it's not what we show the user
1191 if (url == null || url.length() < 1) url = desc.getAttribute("{http://www.w3.org/1999/02/22-rdf-syntax-ns#}about");
1192 if (url == null || url.length() < 1) return;
1193 new FixedURLSpan(url).onClick(v);
1194 });
1195 }
1196 view.setTag(viewHolder);
1197 } else {
1198 viewHolder = (ViewHolder) view.getTag();
1199 if (viewHolder == null) {
1200 return view;
1201 }
1202 }
1203
1204 if (viewHolder.messageBody != null) {
1205 viewHolder.messageBody.setCustomSelectionActionModeCallback(new MessageTextActionModeCallback(this, viewHolder.messageBody));
1206 }
1207
1208 if (viewHolder.time != null) {
1209 if (message.isAttention()) {
1210 viewHolder.time.setTypeface(null, Typeface.BOLD);
1211 } else {
1212 viewHolder.time.setTypeface(null, Typeface.NORMAL);
1213 }
1214 }
1215
1216 final var black = MaterialColors.getColor(view, com.google.android.material.R.attr.colorSecondaryContainer) == view.getContext().getColor(android.R.color.black);
1217 final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
1218 final BubbleColor bubbleColor;
1219 if (type == RECEIVED) {
1220 if (isInValidSession) {
1221 bubbleColor = colorfulBackground || black ? BubbleColor.SECONDARY : BubbleColor.SURFACE;
1222 } else {
1223 bubbleColor = BubbleColor.WARNING;
1224 }
1225 } else {
1226 if (!colorfulBackground && black) {
1227 bubbleColor = BubbleColor.SECONDARY;
1228 } else {
1229 bubbleColor = colorfulBackground ? BubbleColor.TERTIARY : BubbleColor.SURFACE_HIGH;
1230 }
1231 }
1232
1233 if (viewHolder.thread_identicon != null) {
1234 viewHolder.thread_identicon.setVisibility(View.GONE);
1235 final Element thread = message.getThread();
1236 if (thread != null) {
1237 final String threadId = thread.getContent();
1238 if (threadId != null) {
1239 final var roles = MaterialColors.getColorRoles(activity, UIHelper.getColorForName(threadId));
1240 viewHolder.thread_identicon.setVisibility(View.VISIBLE);
1241 viewHolder.thread_identicon.setColor(roles.getAccent());
1242 viewHolder.thread_identicon.setHash(UIHelper.identiconHash(threadId));
1243 }
1244 }
1245 }
1246
1247 if (type == DATE_SEPARATOR) {
1248 if (UIHelper.today(message.getTimeSent())) {
1249 viewHolder.status_message.setText(R.string.today);
1250 } else if (UIHelper.yesterday(message.getTimeSent())) {
1251 viewHolder.status_message.setText(R.string.yesterday);
1252 } else {
1253 viewHolder.status_message.setText(
1254 DateUtils.formatDateTime(
1255 activity,
1256 message.getTimeSent(),
1257 DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR));
1258 }
1259 if (colorfulBackground) {
1260 setBackgroundTint(viewHolder.message_box, BubbleColor.PRIMARY);
1261 setTextColor(viewHolder.status_message, BubbleColor.PRIMARY);
1262 } else {
1263 setBackgroundTint(viewHolder.message_box, BubbleColor.SURFACE_HIGH);
1264 setTextColor(viewHolder.status_message, BubbleColor.SURFACE_HIGH);
1265 }
1266 return view;
1267 } else if (type == RTP_SESSION) {
1268 final boolean received = message.getStatus() <= Message.STATUS_RECEIVED;
1269 final RtpSessionStatus rtpSessionStatus = RtpSessionStatus.of(message.getBody());
1270 final long duration = rtpSessionStatus.duration;
1271 final String callTime = UIHelper.readableTimeDifferenceFull(activity, message.getTimeSent());
1272 if (received) {
1273 if (duration > 0) {
1274 viewHolder.status_message.setText(
1275 activity.getString(
1276 R.string.incoming_call_duration_timestamp,
1277 TimeFrameUtils.resolve(activity, duration),
1278 UIHelper.readableTimeDifferenceFull(
1279 activity, message.getTimeSent())));
1280 } else if (rtpSessionStatus.successful) {
1281 viewHolder.status_message.setText(activity.getString(R.string.incoming_call_timestamp, callTime));
1282 } else {
1283 viewHolder.status_message.setText(
1284 activity.getString(
1285 R.string.missed_call_timestamp,
1286 UIHelper.readableTimeDifferenceFull(
1287 activity, message.getTimeSent())));
1288 }
1289 } else {
1290 if (duration > 0) {
1291 viewHolder.status_message.setText(
1292 activity.getString(
1293 R.string.outgoing_call_duration_timestamp,
1294 TimeFrameUtils.resolve(activity, duration),
1295 UIHelper.readableTimeDifferenceFull(
1296 activity, message.getTimeSent())));
1297 } else {
1298 viewHolder.status_message.setText(
1299 activity.getString(
1300 R.string.outgoing_call_timestamp,
1301 UIHelper.readableTimeDifferenceFull(
1302 activity, message.getTimeSent())));
1303 }
1304 }
1305 if (colorfulBackground) {
1306 setBackgroundTint(viewHolder.message_box, BubbleColor.SECONDARY);
1307 setTextColor(viewHolder.status_message, BubbleColor.SECONDARY);
1308 setImageTint(viewHolder.indicatorReceived, BubbleColor.SECONDARY);
1309 } else {
1310 setBackgroundTint(viewHolder.message_box, BubbleColor.SURFACE_HIGH);
1311 setTextColor(viewHolder.status_message, BubbleColor.SURFACE_HIGH);
1312 setImageTint(viewHolder.indicatorReceived, BubbleColor.SURFACE_HIGH);
1313 }
1314 viewHolder.indicatorReceived.setImageResource(
1315 RtpSessionStatus.getDrawable(received, rtpSessionStatus.successful));
1316 return view;
1317 } else if (type == STATUS) {
1318 if ("LOAD_MORE".equals(message.getBody())) {
1319 viewHolder.status_message.setVisibility(View.GONE);
1320 viewHolder.contact_picture.setVisibility(View.GONE);
1321 viewHolder.load_more_messages.setVisibility(View.VISIBLE);
1322 viewHolder.load_more_messages.setOnClickListener(
1323 v -> loadMoreMessages((Conversation) message.getConversation()));
1324 } else {
1325 viewHolder.status_message.setVisibility(View.VISIBLE);
1326 viewHolder.load_more_messages.setVisibility(View.GONE);
1327 viewHolder.status_message.setText(message.getBody());
1328 boolean showAvatar;
1329 if (conversation.getMode() == Conversation.MODE_SINGLE) {
1330 showAvatar = true;
1331 AvatarWorkerTask.loadAvatar(
1332 message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
1333 } else if (message.getCounterpart() != null
1334 || message.getTrueCounterpart() != null
1335 || (message.getCounterparts() != null
1336 && message.getCounterparts().size() > 0)) {
1337 showAvatar = true;
1338 AvatarWorkerTask.loadAvatar(
1339 message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
1340 } else {
1341 showAvatar = false;
1342 }
1343 if (showAvatar) {
1344 viewHolder.contact_picture.setAlpha(0.5f);
1345 viewHolder.contact_picture.setVisibility(View.VISIBLE);
1346 } else {
1347 viewHolder.contact_picture.setVisibility(View.GONE);
1348 }
1349 }
1350 return view;
1351 } else {
1352 // viewHolder.message_box.setClipToOutline(true); This eats the bubble tails on A14 for some reason
1353 AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar_on_conversation_overview);
1354 }
1355
1356 resetClickListener(viewHolder.message_box, viewHolder.messageBody);
1357
1358 viewHolder.message_box.setOnClickListener(v -> {
1359 if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1360 MessageAdapter.this.mOnMessageBoxClickedListener
1361 .onContactPictureClicked(message);
1362 }
1363 });
1364 SwipeDetector swipeDetector = new SwipeDetector((action) -> {
1365 if (action == SwipeDetector.Action.LR && MessageAdapter.this.mOnMessageBoxSwipedListener != null) {
1366 MessageAdapter.this.mOnMessageBoxSwipedListener.onContactPictureClicked(message);
1367 }
1368 });
1369 viewHolder.message_box.setOnTouchListener(swipeDetector);
1370 viewHolder.image.setOnTouchListener(swipeDetector);
1371 viewHolder.time.setOnTouchListener(swipeDetector);
1372
1373 // Treat touch-up as click so we don't have to touch twice
1374 // (touch twice is because it's waiting to see if you double-touch for text selection)
1375 viewHolder.messageBody.setOnTouchListener((v, event) -> {
1376 if (event.getAction() == MotionEvent.ACTION_UP) {
1377 if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1378 MessageAdapter.this.mOnMessageBoxClickedListener
1379 .onContactPictureClicked(message);
1380 }
1381 }
1382
1383 swipeDetector.onTouch(v, event);
1384
1385 return false;
1386 });
1387 viewHolder.messageBody.setOnClickListener(v -> {
1388 if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1389 MessageAdapter.this.mOnMessageBoxClickedListener
1390 .onContactPictureClicked(message);
1391 }
1392 });
1393 viewHolder.contact_picture.setOnClickListener(v -> {
1394 if (MessageAdapter.this.mOnContactPictureClickedListener != null) {
1395 MessageAdapter.this.mOnContactPictureClickedListener
1396 .onContactPictureClicked(message);
1397 }
1398
1399 });
1400 viewHolder.contact_picture.setOnLongClickListener(v -> {
1401 if (MessageAdapter.this.mOnContactPictureLongClickedListener != null) {
1402 MessageAdapter.this.mOnContactPictureLongClickedListener
1403 .onContactPictureLongClicked(v, message);
1404 return true;
1405 } else {
1406 return false;
1407 }
1408 });
1409 viewHolder.messageBody.setAccessibilityDelegate(null);
1410
1411 boolean footerWrap = false;
1412
1413 final Transferable transferable = message.getTransferable();
1414 final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
1415
1416 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));
1417 if (muted) {
1418 // Muted MUC participant
1419 displayInfoMessage(viewHolder, "Muted", bubbleColor);
1420 } else if (unInitiatedButKnownSize || message.isDeleted() || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING)) {
1421 if (unInitiatedButKnownSize || (message.isDeleted() && message.getModerated() == null) || transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER) {
1422 displayDownloadableMessage(viewHolder, message, activity.getString(R.string.download_x_file, UIHelper.getFileDescriptionString(activity, message)), bubbleColor, type);
1423 } else if (transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER_CHECK_FILESIZE) {
1424 displayDownloadableMessage(viewHolder, message, activity.getString(R.string.check_x_filesize, UIHelper.getFileDescriptionString(activity, message)), bubbleColor, type);
1425 } else {
1426 displayInfoMessage(viewHolder, UIHelper.getMessagePreview(activity.xmppConnectionService, message).first, bubbleColor);
1427 }
1428 } else if (message.isFileOrImage()
1429 && message.getEncryption() != Message.ENCRYPTION_PGP
1430 && message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED) {
1431 if (message.getFileParams().width > 0 && message.getFileParams().height > 0) {
1432 displayMediaPreviewMessage(viewHolder, message, bubbleColor, type);
1433 if (!black && viewHolder.image.getLayoutParams().width > metrics.density * 110) {
1434 footerWrap = true;
1435 }
1436 } else if (message.getFileParams().runtime > 0) {
1437 displayAudioMessage(viewHolder, message, bubbleColor, type);
1438 } else if ("application/webxdc+zip".equals(message.getFileParams().getMediaType()) && message.getConversation() instanceof Conversation && message.getThread() != null && !message.getFileParams().getCids().isEmpty()) {
1439 displayWebxdcMessage(viewHolder, message, bubbleColor, type);
1440 } else {
1441 displayOpenableMessage(viewHolder, message, bubbleColor, type);
1442 }
1443 } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
1444 if (account.isPgpDecryptionServiceConnected()) {
1445 if (conversation instanceof Conversation
1446 && !account.hasPendingPgpIntent((Conversation) conversation)) {
1447 displayInfoMessage(
1448 viewHolder,
1449 activity.getString(R.string.message_decrypting),
1450 bubbleColor);
1451 } else {
1452 displayInfoMessage(
1453 viewHolder, activity.getString(R.string.pgp_message), bubbleColor);
1454 }
1455 } else {
1456 displayInfoMessage(
1457 viewHolder, activity.getString(R.string.install_openkeychain), bubbleColor);
1458 viewHolder.message_box.setOnClickListener(this::promptOpenKeychainInstall);
1459 viewHolder.messageBody.setOnClickListener(this::promptOpenKeychainInstall);
1460 }
1461 } else if (message.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) {
1462 displayInfoMessage(
1463 viewHolder, activity.getString(R.string.decryption_failed), bubbleColor);
1464 } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE) {
1465 displayInfoMessage(
1466 viewHolder,
1467 activity.getString(R.string.not_encrypted_for_this_device),
1468 bubbleColor);
1469 } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_FAILED) {
1470 displayInfoMessage(
1471 viewHolder, activity.getString(R.string.omemo_decryption_failed), bubbleColor);
1472 } else {
1473 if (message.wholeIsKnownURI() != null) {
1474 displayURIMessage(viewHolder, message, bubbleColor, type);
1475 } else if (message.isGeoUri()) {
1476 displayLocationMessage(viewHolder, message, bubbleColor, type);
1477 } else if (message.treatAsDownloadable()) {
1478 try {
1479 final URI uri = message.getOob();
1480 displayDownloadableMessage(viewHolder,
1481 message,
1482 activity.getString(
1483 R.string.check_x_filesize_on_host,
1484 UIHelper.getFileDescriptionString(activity, message),
1485 uri.getHost()),
1486 bubbleColor, type);
1487 } catch (Exception e) {
1488 displayDownloadableMessage(
1489 viewHolder,
1490 message,
1491 activity.getString(
1492 R.string.check_x_filesize,
1493 UIHelper.getFileDescriptionString(activity, message)),
1494 bubbleColor, type);
1495 }
1496 } else if (message.bodyIsOnlyEmojis() && message.getType() != Message.TYPE_PRIVATE) {
1497 displayEmojiMessage(viewHolder, message, bubbleColor, type);
1498 } else {
1499 displayTextMessage(viewHolder, message, bubbleColor, message.getType());
1500 }
1501 }
1502
1503 viewHolder.message_box_inner.setMinimumWidth(footerWrap ? (int) (110 * metrics.density) : 0);
1504 LinearLayout.LayoutParams statusParams = (LinearLayout.LayoutParams) viewHolder.status_line.getLayoutParams();
1505 statusParams.width = footerWrap ? ViewGroup.LayoutParams.MATCH_PARENT : ViewGroup.LayoutParams.WRAP_CONTENT;
1506 viewHolder.status_line.setLayoutParams(statusParams);
1507
1508 setBackgroundTint(viewHolder.message_box, bubbleColor);
1509 setTextColor(viewHolder.messageBody, bubbleColor);
1510 viewHolder.messageBody.setLinkTextColor(bubbleToOnSurfaceColor(viewHolder.messageBody, bubbleColor));
1511
1512 final Function<Reaction, GetThumbnailForCid> reactionThumbnailer = (r) -> new Thumbnailer(conversation.getAccount(), r, conversation.canInferPresence());
1513 if (type == RECEIVED) {
1514 if (!muted && commands != null && conversation instanceof Conversation) {
1515 CommandButtonAdapter adapter = new CommandButtonAdapter(activity);
1516 adapter.addAll(commands);
1517 viewHolder.commands_list.setAdapter(adapter);
1518 viewHolder.commands_list.setVisibility(View.VISIBLE);
1519 viewHolder.commands_list.setOnItemClickListener((p, v, pos, id) -> {
1520 final Element command = adapter.getItem(pos);
1521 activity.startCommand(conversation.getAccount(), command.getAttributeAsJid("jid"), command.getAttribute("node"));
1522 });
1523 } else {
1524 // It's unclear if we can set this to null...
1525 ListAdapter adapter = viewHolder.commands_list.getAdapter();
1526 if (adapter instanceof ArrayAdapter) {
1527 ((ArrayAdapter<?>) adapter).clear();
1528 }
1529 viewHolder.commands_list.setVisibility(View.GONE);
1530 viewHolder.commands_list.setOnItemClickListener(null);
1531 }
1532
1533 setTextColor(viewHolder.encryption, bubbleColor);
1534
1535 if (isInValidSession) {
1536 viewHolder.encryption.setVisibility(View.GONE);
1537 } else {
1538 viewHolder.encryption.setVisibility(View.VISIBLE);
1539 if (omemoEncryption && !message.isTrusted()) {
1540 viewHolder.encryption.setText(R.string.not_trusted);
1541 } else {
1542 viewHolder.encryption.setText(
1543 CryptoHelper.encryptionTypeToText(message.getEncryption()));
1544 }
1545 }
1546 final var aggregatedReactions = conversation instanceof Conversation ? ((Conversation) conversation).aggregatedReactionsFor(message, reactionThumbnailer) : message.getAggregatedReactions();
1547 BindingAdapters.setReactionsOnReceived(
1548 viewHolder.reactions,
1549 conversation instanceof Conversation ? (Conversation) conversation : null,
1550 aggregatedReactions,
1551 reactions -> sendReactions(message, reactions),
1552 emoji -> sendCustomReaction(message, emoji),
1553 reaction -> removeCustomReaction(conversation, reaction),
1554 () -> addReaction(message));
1555 } else if (type == SENT) {
1556 final var aggregatedReactions = conversation instanceof Conversation ? ((Conversation) conversation).aggregatedReactionsFor(message, reactionThumbnailer) : message.getAggregatedReactions();
1557 BindingAdapters.setReactionsOnReceived(
1558 viewHolder.reactions,
1559 conversation instanceof Conversation ? (Conversation) conversation : null,
1560 aggregatedReactions,
1561 reactions -> sendReactions(message, reactions),
1562 emoji -> sendCustomReaction(message, emoji),
1563 reaction -> removeCustomReaction(conversation, reaction),
1564 () -> addReaction(message));
1565 }
1566
1567 if (type == RECEIVED || type == SENT) {
1568 String subject = message.getSubject();
1569 if (subject == null && message.getThread() != null) {
1570 final var thread = ((Conversation) message.getConversation()).getThread(message.getThread().getContent());
1571 if (thread != null) subject = thread.getSubject();
1572 }
1573 if (muted || subject == null) {
1574 viewHolder.subject.setVisibility(View.GONE);
1575 } else {
1576 viewHolder.subject.setVisibility(View.VISIBLE);
1577 viewHolder.subject.setText(subject);
1578 }
1579
1580 if (message.getInReplyTo() == null) {
1581 viewHolder.inReplyToBox.setVisibility(View.GONE);
1582 } else {
1583 viewHolder.inReplyToBox.setVisibility(View.VISIBLE);
1584 viewHolder.inReplyTo.setText(UIHelper.getMessageDisplayName(message.getInReplyTo()));
1585 viewHolder.inReplyTo.setOnClickListener((v) -> mConversationFragment.jumpTo(message.getInReplyTo()));
1586 viewHolder.inReplyToQuote.setOnClickListener((v) -> mConversationFragment.jumpTo(message.getInReplyTo()));
1587 setTextColor(viewHolder.inReplyTo, bubbleColor);
1588 }
1589
1590 if (appSettings.showLinkPreviews()) {
1591 final var descriptions = message.getLinkDescriptions();
1592 viewHolder.link_descriptions.setAdapter(new ArrayAdapter<>(activity, 0, descriptions) {
1593 @Override
1594 public View getView(int position, View view, @NonNull ViewGroup parent) {
1595 final LinkDescriptionBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()), R.layout.link_description, parent, false);
1596 binding.title.setText(getItem(position).findChildContent("title", "https://ogp.me/ns#"));
1597 binding.description.setText(getItem(position).findChildContent("description", "https://ogp.me/ns#"));
1598 binding.url.setText(getItem(position).findChildContent("url", "https://ogp.me/ns#"));
1599 final var video = getItem(position).findChildContent("video", "https://ogp.me/ns#");
1600 if (video != null && video.length() > 0) {
1601 binding.playButton.setVisibility(View.VISIBLE);
1602 binding.playButton.setOnClickListener((v) -> {
1603 new FixedURLSpan(video).onClick(v);
1604 });
1605 }
1606 return binding.getRoot();
1607 }
1608 });
1609 Util.justifyListViewHeightBasedOnChildren(viewHolder.link_descriptions, (int)(metrics.density * 100), true);
1610 }
1611 }
1612
1613 displayStatus(viewHolder, message, type, bubbleColor);
1614
1615 viewHolder.messageBody.setAccessibilityDelegate(new View.AccessibilityDelegate() {
1616 @Override
1617 public void sendAccessibilityEvent(View host, int eventType) {
1618 super.sendAccessibilityEvent(host, eventType);
1619 if (eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) {
1620 if (viewHolder.messageBody.hasSelection()) {
1621 selectionUuid = message.getUuid();
1622 } else if (message.getUuid() != null && message.getUuid().equals(selectionUuid)) {
1623 selectionUuid = null;
1624 }
1625 }
1626 }
1627 });
1628
1629 return view;
1630 }
1631
1632 private void sendReactions(final Message message, final Collection<String> reactions) {
1633 if (activity.xmppConnectionService.sendReactions(message, reactions)) {
1634 return;
1635 }
1636 Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
1637 }
1638
1639 private void sendCustomReaction(final Message inReplyTo, final EmojiSearch.CustomEmoji emoji) {
1640 final var message = inReplyTo.reply();
1641 message.appendBody(emoji.toInsert());
1642 Message.configurePrivateMessage(message);
1643 new Thread(() -> activity.xmppConnectionService.sendMessage(message)).start();
1644 }
1645
1646 private void removeCustomReaction(final Conversational conversation, final Reaction reaction) {
1647 if (!(conversation instanceof Conversation)) {
1648 Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
1649 return;
1650 }
1651
1652 final var message = new Message(conversation, " ", ((Conversation) conversation).getNextEncryption());
1653 final var envelope = ((Conversation) conversation).findMessageWithUuidOrRemoteId(reaction.envelopeId);
1654 if (envelope != null) {
1655 ((Conversation) conversation).remove(envelope);
1656 message.addPayload(envelope.getReply());
1657 message.getOrMakeHtml();
1658 message.putEdited(reaction.envelopeId, envelope.getServerMsgId());
1659 } else {
1660 message.putEdited(reaction.envelopeId, null);
1661 }
1662
1663 new Thread(() -> activity.xmppConnectionService.sendMessage(message)).start();
1664 }
1665
1666 private void addReaction(final Message message) {
1667 activity.addReaction(message, reactions -> activity.xmppConnectionService.sendReactions(message,reactions));
1668 }
1669
1670 private void promptOpenKeychainInstall(View view) {
1671 activity.showInstallPgpDialog();
1672 }
1673
1674 public FileBackend getFileBackend() {
1675 return activity.xmppConnectionService.getFileBackend();
1676 }
1677
1678 public void stopAudioPlayer() {
1679 audioPlayer.stop();
1680 }
1681
1682 public void unregisterListenerInAudioPlayer() {
1683 audioPlayer.unregisterListener();
1684 }
1685
1686 public void startStopPending() {
1687 audioPlayer.startStopPending();
1688 }
1689
1690 public void openDownloadable(Message message) {
1691 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
1692 && ContextCompat.checkSelfPermission(
1693 activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)
1694 != PackageManager.PERMISSION_GRANTED) {
1695 ConversationFragment.registerPendingMessage(activity, message);
1696 ActivityCompat.requestPermissions(
1697 activity,
1698 new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE},
1699 ConversationsActivity.REQUEST_OPEN_MESSAGE);
1700 return;
1701 }
1702 final DownloadableFile file =
1703 activity.xmppConnectionService.getFileBackend().getFile(message);
1704 ViewUtil.view(activity, file);
1705 }
1706
1707 private void showLocation(Message message) {
1708 for (Intent intent : GeoHelper.createGeoIntentsFromMessage(activity, message)) {
1709 if (intent.resolveActivity(getContext().getPackageManager()) != null) {
1710 getContext().startActivity(intent);
1711 return;
1712 }
1713 }
1714 Toast.makeText(
1715 activity,
1716 R.string.no_application_found_to_display_location,
1717 Toast.LENGTH_SHORT)
1718 .show();
1719 }
1720
1721 public void updatePreferences() {
1722 this.bubbleDesign =
1723 new BubbleDesign(appSettings.isColorfulChatBubbles(), appSettings.isLargeFont());
1724 }
1725
1726 public void setHighlightedTerm(List<String> terms) {
1727 this.highlightedTerm = terms == null ? null : StylingHelper.filterHighlightedWords(terms);
1728 }
1729
1730 public interface OnContactPictureClicked {
1731 void onContactPictureClicked(Message message);
1732 }
1733
1734 public interface OnContactPictureLongClicked {
1735 void onContactPictureLongClicked(View v, Message message);
1736 }
1737
1738 public interface OnInlineImageLongClicked {
1739 boolean onInlineImageLongClicked(Cid cid);
1740 }
1741
1742 private static void setBackgroundTint(final View view, final BubbleColor bubbleColor) {
1743 view.setBackgroundTintList(bubbleToColorStateList(view, bubbleColor));
1744 }
1745
1746 private static ColorStateList bubbleToColorStateList(
1747 final View view, final BubbleColor bubbleColor) {
1748 final @AttrRes int colorAttributeResId =
1749 switch (bubbleColor) {
1750 case SURFACE -> Activities.isNightMode(view.getContext())
1751 ? com.google.android.material.R.attr.colorSurfaceContainerHigh
1752 : com.google.android.material.R.attr.colorSurfaceContainerLow;
1753 case SURFACE_HIGH -> Activities.isNightMode(view.getContext())
1754 ? com.google.android.material.R.attr.colorSurfaceContainerHighest
1755 : com.google.android.material.R.attr.colorSurfaceContainerHigh;
1756 case PRIMARY -> com.google.android.material.R.attr.colorPrimaryContainer;
1757 case SECONDARY -> com.google.android.material.R.attr.colorSecondaryContainer;
1758 case TERTIARY -> com.google.android.material.R.attr.colorTertiaryContainer;
1759 case WARNING -> com.google.android.material.R.attr.colorErrorContainer;
1760 };
1761 return ColorStateList.valueOf(MaterialColors.getColor(view, colorAttributeResId));
1762 }
1763
1764 public static void setImageTint(final ImageView imageView, final BubbleColor bubbleColor) {
1765 ImageViewCompat.setImageTintList(
1766 imageView, bubbleToOnSurfaceColorStateList(imageView, bubbleColor));
1767 }
1768
1769 public static void setImageTintError(final ImageView imageView) {
1770 ImageViewCompat.setImageTintList(
1771 imageView,
1772 ColorStateList.valueOf(
1773 MaterialColors.getColor(
1774 imageView, com.google.android.material.R.attr.colorError)));
1775 }
1776
1777 public static void setTextColor(final TextView textView, final BubbleColor bubbleColor) {
1778 final var color = bubbleToOnSurfaceColor(textView, bubbleColor);
1779 textView.setTextColor(color);
1780 if (BubbleColor.SURFACES.contains(bubbleColor)) {
1781 textView.setLinkTextColor(
1782 MaterialColors.getColor(
1783 textView, com.google.android.material.R.attr.colorPrimary));
1784 } else {
1785 textView.setLinkTextColor(color);
1786 }
1787 }
1788
1789 private static void setTextSize(final TextView textView, final boolean largeFont) {
1790 if (largeFont) {
1791 textView.setTextAppearance(
1792 com.google.android.material.R.style.TextAppearance_Material3_BodyLarge);
1793 textView.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 18);
1794 } else {
1795 textView.setTextAppearance(
1796 com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
1797 }
1798 }
1799
1800 private static @ColorInt int bubbleToOnSurfaceVariant(
1801 final View view, final BubbleColor bubbleColor) {
1802 final @AttrRes int colorAttributeResId;
1803 if (BubbleColor.SURFACES.contains(bubbleColor)) {
1804 colorAttributeResId = com.google.android.material.R.attr.colorOnSurfaceVariant;
1805 } else {
1806 colorAttributeResId = bubbleToOnSurface(bubbleColor);
1807 }
1808 return MaterialColors.getColor(view, colorAttributeResId);
1809 }
1810
1811 private static @ColorInt int bubbleToOnSurfaceColor(
1812 final View view, final BubbleColor bubbleColor) {
1813 return MaterialColors.getColor(view, bubbleToOnSurface(bubbleColor));
1814 }
1815
1816 public static ColorStateList bubbleToOnSurfaceColorStateList(
1817 final View view, final BubbleColor bubbleColor) {
1818 return ColorStateList.valueOf(bubbleToOnSurfaceColor(view, bubbleColor));
1819 }
1820
1821 private static @AttrRes int bubbleToOnSurface(final BubbleColor bubbleColor) {
1822 return switch (bubbleColor) {
1823 case SURFACE, SURFACE_HIGH -> com.google.android.material.R.attr.colorOnSurface;
1824 case PRIMARY -> com.google.android.material.R.attr.colorOnPrimaryContainer;
1825 case SECONDARY -> com.google.android.material.R.attr.colorOnSecondaryContainer;
1826 case TERTIARY -> com.google.android.material.R.attr.colorOnTertiaryContainer;
1827 case WARNING -> com.google.android.material.R.attr.colorOnErrorContainer;
1828 };
1829 }
1830
1831 public enum BubbleColor {
1832 SURFACE,
1833 SURFACE_HIGH,
1834 PRIMARY,
1835 SECONDARY,
1836 TERTIARY,
1837 WARNING;
1838
1839 private static final Collection<BubbleColor> SURFACES =
1840 Arrays.asList(BubbleColor.SURFACE, BubbleColor.SURFACE_HIGH);
1841 }
1842
1843 private static class BubbleDesign {
1844 public final boolean colorfulChatBubbles;
1845 public final boolean largeFont;
1846
1847 private BubbleDesign(final boolean colorfulChatBubbles, final boolean largeFont) {
1848 this.colorfulChatBubbles = colorfulChatBubbles;
1849 this.largeFont = largeFont;
1850 }
1851 }
1852
1853 private static class ViewHolder {
1854
1855 public MaterialButton load_more_messages;
1856 public ImageView edit_indicator;
1857 public RelativeLayout audioPlayer;
1858 protected View status_line;
1859 protected LinearLayout message_box;
1860 protected View message_box_inner;
1861 protected MaterialButton download_button;
1862 protected ShapeableImageView image;
1863 protected ImageView indicator;
1864 protected ImageView indicatorReceived;
1865 protected TextView time;
1866 protected TextView subject;
1867 protected TextView inReplyTo;
1868 protected TextView inReplyToQuote;
1869 protected LinearLayout inReplyToBox;
1870 protected TextView messageBody;
1871 protected ImageView contact_picture;
1872 protected TextView status_message;
1873 protected TextView encryption;
1874 protected ListView commands_list;
1875 protected ListView link_descriptions;
1876 protected GithubIdenticonView thread_identicon;
1877 protected ChipGroup reactions;
1878 }
1879
1880 class Thumbnailer implements GetThumbnailForCid {
1881 final Account account;
1882 final boolean canFetch;
1883 final Jid counterpart;
1884
1885 public Thumbnailer(final Message message) {
1886 account = message.getConversation().getAccount();
1887 canFetch = message.trusted() || message.getConversation().canInferPresence();
1888 counterpart = message.getCounterpart();
1889 }
1890
1891 public Thumbnailer(final Account account, final Reaction reaction, final boolean allowFetch) {
1892 canFetch = allowFetch;
1893 counterpart = reaction.from;
1894 this.account = account;
1895 }
1896
1897 @Override
1898 public Drawable getThumbnail(Cid cid) {
1899 try {
1900 DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
1901 if (f == null || !f.canRead()) {
1902 if (!canFetch) return null;
1903
1904 try {
1905 new BobTransfer(BobTransfer.uri(cid), account, counterpart, activity.xmppConnectionService).start();
1906 } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
1907 return null;
1908 }
1909
1910 Drawable d = activity.xmppConnectionService.getFileBackend().getThumbnail(f, activity.getResources(), (int) (metrics.density * 288), true);
1911 if (d == null) {
1912 new ThumbnailTask().execute(f);
1913 }
1914 return d;
1915 } catch (final IOException e) {
1916 return null;
1917 }
1918 }
1919 }
1920
1921 class ThumbnailTask extends AsyncTask<DownloadableFile, Void, Drawable[]> {
1922 @Override
1923 protected Drawable[] doInBackground(DownloadableFile... params) {
1924 if (isCancelled()) return null;
1925
1926 Drawable[] d = new Drawable[params.length];
1927 for (int i = 0; i < params.length; i++) {
1928 try {
1929 d[i] = activity.xmppConnectionService.getFileBackend().getThumbnail(params[i], activity.getResources(), (int) (metrics.density * 288), false);
1930 } catch (final IOException e) {
1931 d[i] = null;
1932 }
1933 }
1934
1935 return d;
1936 }
1937
1938 @Override
1939 protected void onPostExecute(final Drawable[] d) {
1940 if (isCancelled()) return;
1941 activity.xmppConnectionService.updateConversationUi();
1942 }
1943 }
1944}