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