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