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