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.ImageView;
39import android.widget.LinearLayout;
40import android.widget.ListAdapter;
41import android.widget.ListView;
42import android.widget.RelativeLayout;
43import android.widget.TextView;
44import android.widget.Toast;
45import androidx.annotation.AttrRes;
46import androidx.annotation.ColorInt;
47import androidx.annotation.DrawableRes;
48import androidx.annotation.NonNull;
49import androidx.annotation.Nullable;
50import androidx.constraintlayout.widget.ConstraintLayout;
51import androidx.core.app.ActivityCompat;
52import androidx.core.content.ContextCompat;
53import androidx.core.content.res.ResourcesCompat;
54import androidx.core.widget.ImageViewCompat;
55import androidx.databinding.DataBindingUtil;
56
57import com.google.android.material.imageview.ShapeableImageView;
58import com.google.android.material.shape.CornerFamily;
59import com.google.android.material.shape.ShapeAppearanceModel;
60
61import com.cheogram.android.BobTransfer;
62import com.cheogram.android.EmojiSearch;
63import com.cheogram.android.GetThumbnailForCid;
64import com.cheogram.android.MessageTextActionModeCallback;
65import com.cheogram.android.SwipeDetector;
66import com.cheogram.android.Util;
67import com.cheogram.android.WebxdcPage;
68import com.cheogram.android.WebxdcUpdate;
69
70import androidx.emoji2.emojipicker.EmojiViewItem;
71import androidx.emoji2.emojipicker.RecentEmojiProvider;
72
73import com.google.android.material.button.MaterialButton;
74import com.google.android.material.chip.ChipGroup;
75import com.google.android.material.color.MaterialColors;
76import com.google.android.material.dialog.MaterialAlertDialogBuilder;
77import com.google.common.base.Joiner;
78import com.google.common.base.Strings;
79import com.google.common.collect.Collections2;
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.databinding.ItemMessageDateBubbleBinding;
110import eu.siacs.conversations.databinding.ItemMessageEndBinding;
111import eu.siacs.conversations.databinding.ItemMessageRtpSessionBinding;
112import eu.siacs.conversations.databinding.ItemMessageStartBinding;
113import eu.siacs.conversations.databinding.ItemMessageStatusBinding;
114import eu.siacs.conversations.entities.Account;
115import eu.siacs.conversations.entities.Contact;
116import eu.siacs.conversations.entities.Conversation;
117import eu.siacs.conversations.entities.Conversational;
118import eu.siacs.conversations.entities.DownloadableFile;
119import eu.siacs.conversations.entities.Message.FileParams;
120import eu.siacs.conversations.entities.Message;
121import eu.siacs.conversations.entities.MucOptions;
122import eu.siacs.conversations.entities.Reaction;
123import eu.siacs.conversations.entities.Roster;
124import eu.siacs.conversations.entities.RtpSessionStatus;
125import eu.siacs.conversations.entities.Transferable;
126import eu.siacs.conversations.persistance.FileBackend;
127import eu.siacs.conversations.services.MessageArchiveService;
128import eu.siacs.conversations.services.NotificationService;
129import eu.siacs.conversations.ui.Activities;
130import eu.siacs.conversations.ui.BindingAdapters;
131import eu.siacs.conversations.ui.ConversationFragment;
132import eu.siacs.conversations.ui.ConversationsActivity;
133import eu.siacs.conversations.ui.XmppActivity;
134import eu.siacs.conversations.ui.service.AudioPlayer;
135import eu.siacs.conversations.ui.text.DividerSpan;
136import eu.siacs.conversations.ui.text.FixedURLSpan;
137import eu.siacs.conversations.ui.text.QuoteSpan;
138import eu.siacs.conversations.ui.util.Attachment;
139import eu.siacs.conversations.ui.util.AvatarWorkerTask;
140import eu.siacs.conversations.ui.util.MyLinkify;
141import eu.siacs.conversations.ui.util.QuoteHelper;
142import eu.siacs.conversations.ui.util.ShareUtil;
143import eu.siacs.conversations.ui.util.ViewUtil;
144import eu.siacs.conversations.utils.CryptoHelper;
145import eu.siacs.conversations.utils.Emoticons;
146import eu.siacs.conversations.utils.GeoHelper;
147import eu.siacs.conversations.utils.MessageUtils;
148import eu.siacs.conversations.utils.StylingHelper;
149import eu.siacs.conversations.utils.TimeFrameUtils;
150import eu.siacs.conversations.utils.UIHelper;
151import eu.siacs.conversations.xmpp.Jid;
152import eu.siacs.conversations.xmpp.mam.MamReference;
153import eu.siacs.conversations.xml.Element;
154import kotlin.coroutines.Continuation;
155
156import java.net.URI;
157import java.util.Arrays;
158import java.util.Collection;
159import java.util.List;
160import java.util.Locale;
161import java.util.regex.Matcher;
162import java.util.regex.Pattern;
163
164public class MessageAdapter extends ArrayAdapter<Message> {
165
166 public static final String DATE_SEPARATOR_BODY = "DATE_SEPARATOR";
167 private static final int END = 0;
168 private static final int START = 1;
169 private static final int STATUS = 2;
170 private static final int DATE_SEPARATOR = 3;
171 private static final int RTP_SESSION = 4;
172 private final XmppActivity activity;
173 private final AudioPlayer audioPlayer;
174 private List<String> highlightedTerm = null;
175 private final DisplayMetrics metrics;
176 private ConversationFragment mConversationFragment = null;
177 private OnContactPictureClicked mOnContactPictureClickedListener;
178 private OnContactPictureClicked mOnMessageBoxClickedListener;
179 private OnContactPictureClicked mOnMessageBoxSwipedListener;
180 private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
181 private OnInlineImageLongClicked mOnInlineImageLongClickedListener;
182 private BubbleDesign bubbleDesign = new BubbleDesign(false, false, false, true);
183 private final boolean mForceNames;
184 private final Map<String, WebxdcUpdate> lastWebxdcUpdate = new HashMap<>();
185 private String selectionUuid = null;
186 private final AppSettings appSettings;
187
188 public MessageAdapter(
189 final XmppActivity activity, final List<Message> messages, final boolean forceNames) {
190 super(activity, 0, messages);
191 this.audioPlayer = new AudioPlayer(this);
192 this.activity = activity;
193 metrics = getContext().getResources().getDisplayMetrics();
194 appSettings = new AppSettings(activity);
195 updatePreferences();
196 this.mForceNames = forceNames;
197 }
198
199 public MessageAdapter(final XmppActivity activity, final List<Message> messages) {
200 this(activity, messages, false);
201 }
202
203 private static void resetClickListener(View... views) {
204 for (View view : views) {
205 if (view != null) view.setOnClickListener(null);
206 }
207 }
208
209 public void flagScreenOn() {
210 activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
211 }
212
213 public void flagScreenOff() {
214 activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
215 }
216
217 public void setVolumeControl(final int stream) {
218 activity.setVolumeControlStream(stream);
219 }
220
221 public void setOnContactPictureClicked(OnContactPictureClicked listener) {
222 this.mOnContactPictureClickedListener = listener;
223 }
224
225 public void setOnMessageBoxClicked(OnContactPictureClicked listener) {
226 this.mOnMessageBoxClickedListener = listener;
227 }
228
229 public void setOnMessageBoxSwiped(OnContactPictureClicked listener) {
230 this.mOnMessageBoxSwipedListener = listener;
231 }
232
233 public void setConversationFragment(ConversationFragment frag) {
234 mConversationFragment = frag;
235 }
236
237 public void quoteText(String text) {
238 if (mConversationFragment != null) mConversationFragment.quoteText(text);
239 }
240
241 public boolean hasSelection() {
242 return selectionUuid != null;
243 }
244
245 public Activity getActivity() {
246 return activity;
247 }
248
249 public void setOnContactPictureLongClicked(OnContactPictureLongClicked listener) {
250 this.mOnContactPictureLongClickedListener = listener;
251 }
252
253 public void setOnInlineImageLongClicked(OnInlineImageLongClicked listener) {
254 this.mOnInlineImageLongClickedListener = listener;
255 }
256
257 @Override
258 public int getViewTypeCount() {
259 return 5;
260 }
261
262 private static int getItemViewType(final Message message, final boolean alignStart) {
263 if (message.getType() == Message.TYPE_STATUS) {
264 if (DATE_SEPARATOR_BODY.equals(message.getBody())) {
265 return DATE_SEPARATOR;
266 } else {
267 return STATUS;
268 }
269 } else if (message.getType() == Message.TYPE_RTP_SESSION) {
270 return RTP_SESSION;
271 } else if (message.getStatus() <= Message.STATUS_RECEIVED || alignStart) {
272 return START;
273 } else {
274 return END;
275 }
276 }
277
278 @Override
279 public int getItemViewType(final int position) {
280 return getItemViewType(getItem(position), bubbleDesign.alignStart);
281 }
282
283 private void displayStatus(
284 final BubbleMessageItemViewHolder viewHolder,
285 final Message message,
286 final BubbleColor bubbleColor) {
287 final int status = message.getStatus();
288 final boolean error;
289 final Transferable transferable = message.getTransferable();
290 final boolean multiReceived =
291 message.getConversation().getMode() == Conversation.MODE_MULTI
292 && message.getStatus() <= Message.STATUS_RECEIVED;
293 final boolean sent = status != Message.STATUS_RECEIVED;
294 final boolean showUserNickname =
295 message.getConversation().getMode() == Conversation.MODE_MULTI
296 && viewHolder instanceof StartBubbleMessageItemViewHolder;
297 final String fileSize;
298 if (message.isFileOrImage()
299 || transferable != null
300 || MessageUtils.unInitiatedButKnownSize(message)) {
301 final FileParams params = message.getFileParams();
302 fileSize = params.size != null ? UIHelper.filesizeToString(params.size) : null;
303 if (message.getStatus() == Message.STATUS_SEND_FAILED
304 || (transferable != null
305 && (transferable.getStatus() == Transferable.STATUS_FAILED
306 || transferable.getStatus()
307 == Transferable.STATUS_CANCELLED))) {
308 error = true;
309 } else {
310 error = message.getStatus() == Message.STATUS_SEND_FAILED;
311 }
312 } else {
313 fileSize = null;
314 error = message.getStatus() == Message.STATUS_SEND_FAILED;
315 }
316
317 if (sent) {
318 final @DrawableRes Integer receivedIndicator =
319 getMessageStatusAsDrawable(message, status);
320 if (receivedIndicator == null) {
321 viewHolder.indicatorReceived().setVisibility(View.INVISIBLE);
322 } else {
323 viewHolder.indicatorReceived().setImageResource(receivedIndicator);
324 if (status == Message.STATUS_SEND_FAILED) {
325 setImageTintError(viewHolder.indicatorReceived());
326 } else {
327 setImageTint(viewHolder.indicatorReceived(), bubbleColor);
328 }
329 viewHolder.indicatorReceived().setVisibility(View.VISIBLE);
330 }
331 } else {
332 viewHolder.indicatorReceived().setVisibility(View.GONE);
333 }
334 final var additionalStatusInfo = getAdditionalStatusInfo(message, status);
335
336 if (error && sent) {
337 viewHolder
338 .time()
339 .setTextColor(
340 MaterialColors.getColor(
341 viewHolder.time(),
342 com.google.android.material.R.attr.colorError));
343 } else {
344 setTextColor(viewHolder.time(), bubbleColor);
345 }
346 setTextColor(viewHolder.subject(), bubbleColor);
347 if (message.getEncryption() == Message.ENCRYPTION_NONE) {
348 viewHolder.indicatorSecurity().setVisibility(View.GONE);
349 } else {
350 boolean verified = false;
351 if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
352 final FingerprintStatus fingerprintStatus =
353 message.getConversation()
354 .getAccount()
355 .getAxolotlService()
356 .getFingerprintTrust(message.getFingerprint());
357 if (fingerprintStatus != null && fingerprintStatus.isVerified()) {
358 verified = true;
359 }
360 }
361 if (verified) {
362 viewHolder.indicatorSecurity().setImageResource(R.drawable.ic_verified_user_24dp);
363 } else {
364 viewHolder.indicatorSecurity().setImageResource(R.drawable.ic_lock_24dp);
365 }
366 if (error && sent) {
367 setImageTintError(viewHolder.indicatorSecurity());
368 } else {
369 setImageTint(viewHolder.indicatorSecurity(), bubbleColor);
370 }
371 viewHolder.indicatorSecurity().setVisibility(View.VISIBLE);
372 }
373
374 if (message.edited()) {
375 viewHolder.indicatorEdit().setVisibility(View.VISIBLE);
376 if (error && sent) {
377 setImageTintError(viewHolder.indicatorEdit());
378 } else {
379 setImageTint(viewHolder.indicatorEdit(), bubbleColor);
380 }
381 } else {
382 viewHolder.indicatorEdit().setVisibility(View.GONE);
383 }
384
385 final String formattedTime =
386 UIHelper.readableTimeDifferenceFull(getContext(), message.getTimeSent());
387 final String bodyLanguage = message.getBodyLanguage();
388 final ImmutableList.Builder<String> timeInfoBuilder = new ImmutableList.Builder<>();
389
390 if (mForceNames || multiReceived || showUserNickname || (message.getTrueCounterpart() != null && message.getContact() != null)) {
391 final String displayName = UIHelper.getMessageDisplayName(message);
392 if (displayName != null) {
393 timeInfoBuilder.add(displayName);
394 }
395 }
396 if (fileSize != null) {
397 timeInfoBuilder.add(fileSize);
398 }
399 if (bodyLanguage != null) {
400 timeInfoBuilder.add(bodyLanguage.toUpperCase(Locale.US));
401 }
402 // for space reasons we display only 'additional status info' (send progress or concrete
403 // failure reason) or the time
404 if (additionalStatusInfo != null) {
405 timeInfoBuilder.add(additionalStatusInfo);
406 } else {
407 timeInfoBuilder.add(formattedTime);
408 }
409 final var timeInfo = timeInfoBuilder.build();
410 viewHolder.time().setText(Joiner.on(" · ").join(timeInfo));
411 }
412
413 public static @DrawableRes Integer getMessageStatusAsDrawable(
414 final Message message, final int status) {
415 final var transferable = message.getTransferable();
416 return switch (status) {
417 case Message.STATUS_WAITING -> R.drawable.ic_more_horiz_24dp;
418 case Message.STATUS_UNSEND -> transferable == null ? null : R.drawable.ic_upload_24dp;
419 case Message.STATUS_SEND -> R.drawable.ic_done_24dp;
420 case Message.STATUS_SEND_RECEIVED, Message.STATUS_SEND_DISPLAYED ->
421 R.drawable.ic_done_all_24dp;
422 case Message.STATUS_SEND_FAILED -> {
423 final String errorMessage = message.getErrorMessage();
424 if (Message.ERROR_MESSAGE_CANCELLED.equals(errorMessage)) {
425 yield R.drawable.ic_cancel_24dp;
426 } else {
427 yield R.drawable.ic_error_24dp;
428 }
429 }
430 case Message.STATUS_OFFERED -> R.drawable.ic_p2p_24dp;
431 default -> null;
432 };
433 }
434
435 @Nullable
436 private String getAdditionalStatusInfo(final Message message, final int mergedStatus) {
437 final String additionalStatusInfo;
438 if (mergedStatus == Message.STATUS_SEND_FAILED) {
439 final String errorMessage = Strings.nullToEmpty(message.getErrorMessage());
440 final String[] errorParts = errorMessage.split("\\u001f", 2);
441 if (errorParts.length == 2 && errorParts[0].equals("file-too-large")) {
442 additionalStatusInfo = getContext().getString(R.string.file_too_large);
443 } else {
444 additionalStatusInfo = null;
445 }
446 } else if (mergedStatus == Message.STATUS_UNSEND) {
447 final var transferable = message.getTransferable();
448 if (transferable == null) {
449 return null;
450 }
451 return getContext().getString(R.string.sending_file, transferable.getProgress());
452 } else {
453 additionalStatusInfo = null;
454 }
455 return additionalStatusInfo;
456 }
457
458 private void displayInfoMessage(
459 BubbleMessageItemViewHolder viewHolder,
460 CharSequence text,
461 final BubbleColor bubbleColor) {
462 viewHolder.downloadButton().setVisibility(View.GONE);
463 viewHolder.audioPlayer().setVisibility(View.GONE);
464 viewHolder.image().setVisibility(View.GONE);
465 viewHolder.messageBody().setTypeface(null, Typeface.ITALIC);
466 viewHolder.messageBody().setVisibility(View.VISIBLE);
467 viewHolder.messageBody().setText(text);
468 viewHolder
469 .messageBody()
470 .setTextColor(bubbleToOnSurfaceVariant(viewHolder.messageBody(), bubbleColor));
471 viewHolder.messageBody().setTextIsSelectable(false);
472 }
473
474 private void displayEmojiMessage(
475 final BubbleMessageItemViewHolder viewHolder,
476 final Message message,
477 final BubbleColor bubbleColor) {
478 displayTextMessage(viewHolder, message, bubbleColor);
479 viewHolder.downloadButton().setVisibility(View.GONE);
480 viewHolder.audioPlayer().setVisibility(View.GONE);
481 viewHolder.image().setVisibility(View.GONE);
482 viewHolder.messageBody().setTypeface(null, Typeface.NORMAL);
483 viewHolder.messageBody().setVisibility(View.VISIBLE);
484 setTextColor(viewHolder.messageBody(), bubbleColor);
485 final var body = getSpannableBody(message);
486 ImageSpan[] imageSpans = body.getSpans(0, body.length(), ImageSpan.class);
487 float size = imageSpans.length == 1 || Emoticons.isEmoji(body.toString()) ? 5.0f : 2.0f;
488 body.setSpan(
489 new RelativeSizeSpan(size), 0, body.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
490 viewHolder.messageBody().setText(body);
491 }
492
493 private void applyQuoteSpan(
494 final TextView textView,
495 Editable body,
496 int start,
497 int end,
498 final BubbleColor bubbleColor,
499 final boolean makeEdits) {
500 if (makeEdits && start > 1 && !"\n\n".equals(body.subSequence(start - 2, start).toString())) {
501 body.insert(start++, "\n");
502 body.setSpan(
503 new DividerSpan(false), start - 2, start, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
504 end++;
505 }
506 if (makeEdits && end < body.length() - 1 && !"\n\n".equals(body.subSequence(end, end + 2).toString())) {
507 body.insert(end, "\n");
508 body.setSpan(
509 new DividerSpan(false),
510 end,
511 end + ("\n".equals(body.subSequence(end + 1, end + 2).toString()) ? 2 : 1),
512 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
513 );
514 }
515 final DisplayMetrics metrics = getContext().getResources().getDisplayMetrics();
516 body.setSpan(
517 new QuoteSpan(bubbleToOnSurfaceVariant(textView, bubbleColor), metrics),
518 start,
519 end,
520 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
521 }
522
523 public boolean handleTextQuotes(final TextView textView, final Editable body) {
524 return handleTextQuotes(textView, body, true);
525 }
526
527 public boolean handleTextQuotes(final TextView textView, final Editable body, final boolean deleteMarkers) {
528 final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
529 final BubbleColor bubbleColor = colorfulBackground ? (deleteMarkers ? BubbleColor.SECONDARY : BubbleColor.TERTIARY) : BubbleColor.SURFACE;
530 return handleTextQuotes(textView, body, bubbleColor, deleteMarkers);
531 }
532
533 /**
534 * Applies QuoteSpan to group of lines which starts with > or » characters. Appends likebreaks
535 * and applies DividerSpan to them to show a padding between quote and text.
536 */
537 public boolean handleTextQuotes(
538 final TextView textView,
539 final Editable body,
540 final BubbleColor bubbleColor,
541 final boolean deleteMarkers) {
542 boolean startsWithQuote = false;
543 int quoteDepth = 1;
544 while (QuoteHelper.bodyContainsQuoteStart(body) && quoteDepth <= Config.QUOTE_MAX_DEPTH) {
545 char previous = '\n';
546 int lineStart = -1;
547 int lineTextStart = -1;
548 int quoteStart = -1;
549 int skipped = 0;
550 for (int i = 0; i <= body.length(); i++) {
551 if (!deleteMarkers && QuoteHelper.isRelativeSizeSpanned(body, i)) {
552 skipped++;
553 continue;
554 }
555 char current = body.length() > i ? body.charAt(i) : '\n';
556 if (lineStart == -1) {
557 if (previous == '\n') {
558 if (i < body.length() && QuoteHelper.isPositionQuoteStart(body, i)) {
559 // Line start with quote
560 lineStart = i;
561 if (quoteStart == -1) quoteStart = i - skipped;
562 if (i == 0) startsWithQuote = true;
563 } else if (quoteStart >= 0) {
564 // Line start without quote, apply spans there
565 applyQuoteSpan(textView, body, quoteStart, i - 1, bubbleColor, deleteMarkers);
566 quoteStart = -1;
567 }
568 }
569 } else {
570 // Remove extra spaces between > and first character in the line
571 // > character will be removed too
572 if (current != ' ' && lineTextStart == -1) {
573 lineTextStart = i;
574 }
575 if (current == '\n') {
576 if (deleteMarkers) {
577 i -= lineTextStart - lineStart;
578 body.delete(lineStart, lineTextStart);
579 if (i == lineStart) {
580 // Avoid empty lines because span over empty line can be hidden
581 body.insert(i++, " ");
582 }
583 } else {
584 body.setSpan(new RelativeSizeSpan(i - (lineTextStart - lineStart) == lineStart ? 1 : 0), lineStart, lineTextStart, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE | StylingHelper.XHTML_REMOVE << Spanned.SPAN_USER_SHIFT);
585 }
586 lineStart = -1;
587 lineTextStart = -1;
588 }
589 }
590 previous = current;
591 skipped = 0;
592 }
593 if (quoteStart >= 0) {
594 // Apply spans to finishing open quote
595 applyQuoteSpan(textView, body, quoteStart, body.length(), bubbleColor, deleteMarkers);
596 }
597 quoteDepth++;
598 }
599 return startsWithQuote;
600 }
601
602 private SpannableStringBuilder getSpannableBody(final Message message) {
603 Drawable fallbackImg = ResourcesCompat.getDrawable(activity.getResources(), R.drawable.ic_photo_24dp, null);
604 return message.getSpannableBody(new Thumbnailer(message), fallbackImg);
605 }
606
607 private void displayTextMessage(
608 final BubbleMessageItemViewHolder viewHolder,
609 final Message message,
610 final BubbleColor bubbleColor) {
611 viewHolder.inReplyToQuote().setVisibility(View.GONE);
612 viewHolder.downloadButton().setVisibility(View.GONE);
613 viewHolder.image().setVisibility(View.GONE);
614 viewHolder.audioPlayer().setVisibility(View.GONE);
615 viewHolder.messageBody().setVisibility(View.VISIBLE);
616 setTextColor(viewHolder.messageBody(), bubbleColor);
617 setTextSize(viewHolder.messageBody(), this.bubbleDesign.largeFont);
618 viewHolder.messageBody().setTypeface(null, Typeface.NORMAL);
619
620 final ViewGroup.LayoutParams layoutParams = viewHolder.messageBody().getLayoutParams();
621 layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT;
622 viewHolder.messageBody().setLayoutParams(layoutParams);
623
624 final ViewGroup.LayoutParams qlayoutParams = viewHolder.inReplyToQuote().getLayoutParams();
625 qlayoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT;
626 viewHolder.inReplyToQuote().setLayoutParams(qlayoutParams);
627
628 final var rawBody = message.getBody();
629 if (Strings.isNullOrEmpty(rawBody)) {
630 viewHolder.messageBody().setText("");
631 viewHolder.messageBody().setTextIsSelectable(false);
632 toggleWhisperInfo(viewHolder, message, bubbleColor);
633 return;
634 }
635 viewHolder.messageBody().setTextIsSelectable(true);
636 final String nick = UIHelper.getMessageDisplayName(message);
637 SpannableStringBuilder body = getSpannableBody(message);
638 final var processMarkup = body.getSpans(0, body.length(), Message.PlainTextSpan.class).length > 0;
639 if (body.length() > Config.MAX_DISPLAY_MESSAGE_CHARS) {
640 body = new SpannableStringBuilder(body, 0, Config.MAX_DISPLAY_MESSAGE_CHARS);
641 body.append("…");
642 }
643 if (processMarkup) StylingHelper.format(body, viewHolder.messageBody().getCurrentTextColor());
644 MyLinkify.addLinks(body, message.getConversation().getAccount(), message.getConversation().getJid());
645 boolean startsWithQuote = processMarkup ? handleTextQuotes(viewHolder.messageBody(), body, bubbleColor, true) : false;
646 for (final android.text.style.QuoteSpan quote : body.getSpans(0, body.length(), android.text.style.QuoteSpan.class)) {
647 int start = body.getSpanStart(quote);
648 int end = body.getSpanEnd(quote);
649 if (start < 0 || end < 0) continue;
650
651 body.removeSpan(quote);
652 applyQuoteSpan(viewHolder.messageBody(), body, start, end, bubbleColor, true);
653 if (start == 0) {
654 if (message.getInReplyTo() == null) {
655 startsWithQuote = true;
656 } else {
657 viewHolder.inReplyToQuote().setText(body.subSequence(start, end));
658 viewHolder.inReplyToQuote().setVisibility(View.VISIBLE);
659 body.delete(start, end);
660 while (body.length() > start && body.charAt(start) == '\n') body.delete(start, 1); // Newlines after quote
661 continue;
662 }
663 }
664 }
665 boolean hasMeCommand = body.toString().startsWith(Message.ME_COMMAND);
666 if (hasMeCommand) {
667 body.replace(0, Message.ME_COMMAND.length(), String.format("%s ", nick));
668 }
669 if (!message.isPrivateMessage()) {
670 if (hasMeCommand && body.length() > nick.length()) {
671 body.setSpan(
672 new StyleSpan(Typeface.BOLD_ITALIC),
673 0,
674 nick.length(),
675 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
676 }
677 } else {
678 String privateMarker;
679 if (message.getStatus() <= Message.STATUS_RECEIVED) {
680 privateMarker = activity.getString(R.string.private_message);
681 } else {
682 Jid cp = message.getCounterpart();
683 privateMarker =
684 activity.getString(
685 R.string.private_message_to,
686 Strings.nullToEmpty(cp == null ? null : cp.getResource()));
687 }
688 body.insert(0, privateMarker);
689 int privateMarkerIndex = privateMarker.length();
690 if (startsWithQuote) {
691 body.insert(privateMarkerIndex, "\n\n");
692 body.setSpan(
693 new DividerSpan(false),
694 privateMarkerIndex,
695 privateMarkerIndex + 2,
696 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
697 } else {
698 body.insert(privateMarkerIndex, " ");
699 }
700 body.setSpan(
701 new ForegroundColorSpan(
702 bubbleToOnSurfaceVariant(viewHolder.messageBody(), bubbleColor)),
703 0,
704 privateMarkerIndex,
705 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
706 body.setSpan(
707 new StyleSpan(Typeface.BOLD),
708 0,
709 privateMarkerIndex,
710 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
711 if (hasMeCommand) {
712 body.setSpan(
713 new StyleSpan(Typeface.BOLD_ITALIC),
714 privateMarkerIndex + 1,
715 privateMarkerIndex + 1 + nick.length(),
716 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
717 }
718 }
719 if (message.getConversation().getMode() == Conversation.MODE_MULTI
720 && message.getStatus() == Message.STATUS_RECEIVED) {
721 if (message.getConversation() instanceof Conversation conversation) {
722 Pattern pattern =
723 NotificationService.generateNickHighlightPattern(
724 conversation.getMucOptions().getActualNick());
725 Matcher matcher = pattern.matcher(body);
726 while (matcher.find()) {
727 body.setSpan(
728 new StyleSpan(Typeface.BOLD),
729 matcher.start(),
730 matcher.end(),
731 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
732 }
733 }
734 }
735
736 for (final var emoji : EmojiManager.extractEmojisInOrderWithIndex(body.toString())) {
737 var end = emoji.getCharIndex() + emoji.getEmoji().getEmoji().length();
738 if (body.length() > end && body.charAt(end) == '\uFE0F') end++;
739 body.setSpan(
740 new RelativeSizeSpan(1.2f),
741 emoji.getCharIndex(),
742 end,
743 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
744 }
745 // Make custom emoji bigger too, to match emoji
746 for (final var span : body.getSpans(0, body.length(), com.cheogram.android.InlineImageSpan.class)) {
747 body.setSpan(
748 new RelativeSizeSpan(1.2f),
749 body.getSpanStart(span),
750 body.getSpanEnd(span),
751 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
752 }
753
754 if (highlightedTerm != null) {
755 StylingHelper.highlight(viewHolder.messageBody(), body, highlightedTerm);
756 }
757
758 viewHolder.messageBody().setAutoLinkMask(0);
759 viewHolder.messageBody().setText(body);
760 if (body.length() <= 0) viewHolder.messageBody().setVisibility(View.GONE);
761 BetterLinkMovementMethod method = new BetterLinkMovementMethod() {
762 @Override
763 protected void dispatchUrlLongClick(TextView tv, ClickableSpan span) {
764 if (span instanceof URLSpan || mOnInlineImageLongClickedListener == null) {
765 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
766 super.dispatchUrlLongClick(tv, span);
767 return;
768 }
769
770 Spannable body = (Spannable) tv.getText();
771 ImageSpan[] imageSpans = body.getSpans(body.getSpanStart(span), body.getSpanEnd(span), ImageSpan.class);
772 if (imageSpans.length > 0) {
773 Uri uri = Uri.parse(imageSpans[0].getSource());
774 Cid cid = BobTransfer.cid(uri);
775 if (cid == null) return;
776 if (mOnInlineImageLongClickedListener.onInlineImageLongClicked(cid)) {
777 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
778 }
779 }
780 }
781 };
782 method.setOnLinkLongClickListener((tv, url) -> {
783 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
784 ShareUtil.copyLinkToClipboard(activity, url);
785 return true;
786 });
787 viewHolder.messageBody().setMovementMethod(method);
788 }
789
790 private void displayDownloadableMessage(
791 final BubbleMessageItemViewHolder viewHolder,
792 final Message message,
793 String text,
794 final BubbleColor bubbleColor) {
795 displayTextMessage(viewHolder, message, bubbleColor);
796 viewHolder.image().setVisibility(View.GONE);
797 List<Element> thumbs = message.getFileParams() != null ? message.getFileParams().getThumbnails() : null;
798 if (thumbs != null && !thumbs.isEmpty()) {
799 for (Element thumb : thumbs) {
800 Uri uri = Uri.parse(thumb.getAttribute("uri"));
801 if (uri.getScheme().equals("data")) {
802 String[] parts = uri.getSchemeSpecificPart().split(",", 2);
803 parts = parts[0].split(";");
804 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;
805 } else if (uri.getScheme().equals("cid")) {
806 Cid cid = BobTransfer.cid(uri);
807 if (cid == null) continue;
808 DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
809 if (f == null || !f.canRead()) {
810 if (!message.trusted() && !message.getConversation().canInferPresence()) continue;
811
812 try {
813 new BobTransfer(BobTransfer.uri(cid), message.getConversation().getAccount(), message.getCounterpart(), activity.xmppConnectionService).start();
814 } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
815 continue;
816 }
817 } else {
818 continue;
819 }
820
821 int width = message.getFileParams().width;
822 if (width < 1 && thumb.getAttribute("width") != null) width = Integer.parseInt(thumb.getAttribute("width"));
823 if (width < 1) width = 1920;
824
825 int height = message.getFileParams().height;
826 if (height < 1 && thumb.getAttribute("height") != null) height = Integer.parseInt(thumb.getAttribute("height"));
827 if (height < 1) height = 1080;
828
829 viewHolder.image().setVisibility(View.VISIBLE);
830 imagePreviewLayout(width, height, viewHolder.image(), message.getInReplyTo() != null, true, viewHolder);
831 activity.loadBitmap(message, viewHolder.image());
832 viewHolder.image().setOnClickListener(v -> ConversationFragment.downloadFile(activity, message));
833
834 break;
835 }
836 }
837 viewHolder.audioPlayer().setVisibility(View.GONE);
838 viewHolder.downloadButton().setVisibility(View.VISIBLE);
839 viewHolder.downloadButton().setText(text);
840 final var attachment = Attachment.of(message);
841 final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
842 viewHolder.downloadButton().setIconResource(imageResource);
843 viewHolder
844 .downloadButton()
845 .setOnClickListener(v -> ConversationFragment.downloadFile(activity, message));
846 }
847
848 private void displayWebxdcMessage(BubbleMessageItemViewHolder viewHolder, final Message message, final BubbleColor bubbleColor) {
849 Cid webxdcCid = message.getFileParams().getCids().get(0);
850 WebxdcPage webxdc = new WebxdcPage(activity, webxdcCid, message);
851 displayTextMessage(viewHolder, message, bubbleColor);
852 viewHolder.image().setVisibility(View.GONE);
853 viewHolder.audioPlayer().setVisibility(View.GONE);
854 viewHolder.downloadButton().setVisibility(View.VISIBLE);
855 viewHolder.downloadButton().setIconResource(0);
856 viewHolder.downloadButton().setText("Open " + webxdc.getName());
857 viewHolder.downloadButton().setOnClickListener(v -> {
858 Conversation conversation = (Conversation) message.getConversation();
859 if (!conversation.switchToSession("webxdc\0" + message.getUuid())) {
860 conversation.startWebxdc(webxdc);
861 }
862 });
863 viewHolder.image().setOnClickListener(v -> {
864 Conversation conversation = (Conversation) message.getConversation();
865 if (!conversation.switchToSession("webxdc\0" + message.getUuid())) {
866 conversation.startWebxdc(webxdc);
867 }
868 });
869
870 final WebxdcUpdate lastUpdate;
871 synchronized(lastWebxdcUpdate) { lastUpdate = lastWebxdcUpdate.get(message.getUuid()); }
872 if (lastUpdate == null) {
873 new Thread(() -> {
874 final WebxdcUpdate update = activity.xmppConnectionService.findLastWebxdcUpdate(message);
875 if (update != null) {
876 synchronized(lastWebxdcUpdate) { lastWebxdcUpdate.put(message.getUuid(), update); }
877 activity.xmppConnectionService.updateConversationUi();
878 }
879 }).start();
880 } else {
881 if (lastUpdate != null && (lastUpdate.getSummary() != null || lastUpdate.getDocument() != null)) {
882 viewHolder.messageBody().setVisibility(View.VISIBLE);
883 viewHolder.messageBody().setText(
884 (lastUpdate.getDocument() == null ? "" : lastUpdate.getDocument() + "\n") +
885 (lastUpdate.getSummary() == null ? "" : lastUpdate.getSummary())
886 );
887 }
888 }
889
890 final LruCache<String, Drawable> cache = activity.xmppConnectionService.getDrawableCache();
891 final Drawable d = cache.get("webxdc:icon:" + webxdcCid);
892 if (d == null) {
893 new Thread(() -> {
894 Drawable icon = webxdc.getIcon();
895 if (icon != null) {
896 cache.put("webxdc:icon:" + webxdcCid, icon);
897 activity.xmppConnectionService.updateConversationUi();
898 }
899 }).start();
900 } else {
901 viewHolder.image().setVisibility(View.VISIBLE);
902 viewHolder.image().setImageDrawable(d);
903 imagePreviewLayout(d.getIntrinsicWidth(), d.getIntrinsicHeight(), viewHolder.image(), message.getInReplyTo() != null, true, viewHolder);
904 }
905 }
906
907 private void displayOpenableMessage(
908 final BubbleMessageItemViewHolder viewHolder,
909 final Message message,
910 final BubbleColor bubbleColor) {
911 displayTextMessage(viewHolder, message, bubbleColor);
912 viewHolder.image().setVisibility(View.GONE);
913 viewHolder.audioPlayer().setVisibility(View.GONE);
914 viewHolder.downloadButton().setVisibility(View.VISIBLE);
915 viewHolder
916 .downloadButton()
917 .setText(
918 activity.getString(
919 R.string.open_x_file,
920 UIHelper.getFileDescriptionString(activity, message)));
921 final var attachment = Attachment.of(message);
922 final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
923 viewHolder.downloadButton().setIconResource(imageResource);
924 viewHolder.downloadButton().setOnClickListener(v -> openDownloadable(message));
925 }
926
927 private void displayURIMessage(
928 BubbleMessageItemViewHolder viewHolder, final Message message, final BubbleColor bubbleColor) {
929 displayTextMessage(viewHolder, message, bubbleColor);
930 viewHolder.messageBody().setVisibility(View.GONE);
931 viewHolder.image().setVisibility(View.GONE);
932 viewHolder.audioPlayer().setVisibility(View.GONE);
933 viewHolder.downloadButton().setVisibility(View.VISIBLE);
934 final var uri = message.wholeIsKnownURI();
935 if ("bitcoin".equals(uri.getScheme())) {
936 final var amount = uri.getQueryParameter("amount");
937 final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
938 viewHolder.downloadButton().setIconResource(R.drawable.bitcoin_24dp);
939 viewHolder.downloadButton().setText("Send " + formattedAmount + "Bitcoin");
940 } else if ("bitcoincash".equals(uri.getScheme())) {
941 final var amount = uri.getQueryParameter("amount");
942 final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
943 viewHolder.downloadButton().setIconResource(R.drawable.bitcoin_cash_24dp);
944 viewHolder.downloadButton().setText("Send " + formattedAmount + "Bitcoin Cash");
945 } else if ("ethereum".equals(uri.getScheme())) {
946 final var amount = uri.getQueryParameter("value");
947 final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
948 viewHolder.downloadButton().setIconResource(R.drawable.eth_24dp);
949 viewHolder.downloadButton().setText("Send " + formattedAmount + "via Ethereum");
950 } else if ("monero".equals(uri.getScheme())) {
951 final var amount = uri.getQueryParameter("tx_amount");
952 final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
953 viewHolder.downloadButton().setIconResource(R.drawable.monero_24dp);
954 viewHolder.downloadButton().setText("Send " + formattedAmount + "Monero");
955 } else if ("wownero".equals(uri.getScheme())) {
956 final var amount = uri.getQueryParameter("tx_amount");
957 final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
958 viewHolder.downloadButton().setIconResource(R.drawable.wownero_24dp);
959 viewHolder.downloadButton().setText("Send " + formattedAmount + "Wownero");
960 }
961 viewHolder.downloadButton().setOnClickListener(v -> new FixedURLSpan(message.getRawBody()).onClick(v));
962 }
963
964 private void displayLocationMessage(
965 final BubbleMessageItemViewHolder viewHolder,
966 final Message message,
967 final BubbleColor bubbleColor) {
968 displayTextMessage(viewHolder, message, bubbleColor);
969 viewHolder.image().setVisibility(View.GONE);
970 viewHolder.audioPlayer().setVisibility(View.GONE);
971 viewHolder.downloadButton().setVisibility(View.VISIBLE);
972 viewHolder.downloadButton().setText(R.string.show_location);
973 final var attachment = Attachment.of(message);
974 final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
975 viewHolder.downloadButton().setIconResource(imageResource);
976 viewHolder.downloadButton().setOnClickListener(v -> showLocation(message));
977 }
978
979 private void displayAudioMessage(
980 final BubbleMessageItemViewHolder viewHolder,
981 Message message,
982 final BubbleColor bubbleColor) {
983 displayTextMessage(viewHolder, message, bubbleColor);
984 viewHolder.image().setVisibility(View.GONE);
985 viewHolder.downloadButton().setVisibility(View.GONE);
986 final RelativeLayout audioPlayer = viewHolder.audioPlayer();
987 audioPlayer.setVisibility(View.VISIBLE);
988 AudioPlayer.ViewHolder.get(audioPlayer).setBubbleColor(bubbleColor);
989 this.audioPlayer.init(audioPlayer, message);
990 }
991
992 private void displayMediaPreviewMessage(
993 final BubbleMessageItemViewHolder viewHolder,
994 final Message message,
995 final BubbleColor bubbleColor) {
996 displayTextMessage(viewHolder, message, bubbleColor);
997 viewHolder.downloadButton().setVisibility(View.GONE);
998 viewHolder.audioPlayer().setVisibility(View.GONE);
999 viewHolder.image().setVisibility(View.VISIBLE);
1000 final FileParams params = message.getFileParams();
1001 imagePreviewLayout(params.width, params.height, viewHolder.image(), message.getInReplyTo() != null, viewHolder.messageBody().getVisibility() != View.GONE, viewHolder);
1002 activity.loadBitmap(message, viewHolder.image());
1003 viewHolder.image().setOnClickListener(v -> openDownloadable(message));
1004 }
1005
1006 private void imagePreviewLayout(int w, int h, ShapeableImageView image, boolean otherAbove, boolean otherBelow, BubbleMessageItemViewHolder viewHolder) {
1007 final float target = activity.getResources().getDimension(R.dimen.image_preview_width);
1008 final int scaledW;
1009 final int scaledH;
1010 if (Math.max(h, w) * metrics.density <= target) {
1011 scaledW = (int) (w * metrics.density);
1012 scaledH = (int) (h * metrics.density);
1013 } else if (Math.max(h, w) <= target) {
1014 scaledW = w;
1015 scaledH = h;
1016 } else if (w <= h) {
1017 scaledW = (int) (w / ((double) h / target));
1018 scaledH = (int) target;
1019 } else {
1020 scaledW = (int) target;
1021 scaledH = (int) (h / ((double) w / target));
1022 }
1023 final var bodyWidth = Math.max(viewHolder.messageBody().getWidth(), viewHolder.downloadButton().getWidth() + (20 * metrics.density));
1024 var targetImageWidth = 200 * metrics.density;
1025 if (!otherBelow) targetImageWidth = 110 * metrics.density;
1026 if (bodyWidth > 0 && bodyWidth < targetImageWidth) targetImageWidth = bodyWidth;
1027 final var small = scaledW < targetImageWidth;
1028 final LinearLayout.LayoutParams layoutParams =
1029 new LinearLayout.LayoutParams(scaledW, scaledH);
1030 image.setLayoutParams(layoutParams);
1031
1032 final var bubbleRadius = activity.getResources().getDimension(R.dimen.bubble_radius);
1033 var shape = new ShapeAppearanceModel.Builder();
1034 if (!otherAbove) {
1035 shape = shape.setTopRightCorner(CornerFamily.ROUNDED, bubbleRadius);
1036 if (viewHolder instanceof EndBubbleMessageItemViewHolder) {
1037 shape = shape.setTopLeftCorner(CornerFamily.ROUNDED, bubbleRadius);
1038 }
1039 }
1040 if (small) {
1041 final var imageRadius = activity.getResources().getDimension(R.dimen.image_radius);
1042 shape = shape.setAllCorners(CornerFamily.ROUNDED, imageRadius);
1043 image.setPadding(0, (int)(8 * metrics.density), 0, 0);
1044 } else {
1045 image.setPadding(0, 0, 0, 0);
1046 }
1047 image.setShapeAppearanceModel(shape.build());
1048
1049 if (!small) {
1050 final ViewGroup.LayoutParams blayoutParams = viewHolder.messageBody().getLayoutParams();
1051 blayoutParams.width = (int) (scaledW - (22 * metrics.density));
1052 viewHolder.messageBody().setLayoutParams(blayoutParams);
1053
1054 final ViewGroup.LayoutParams qlayoutParams = viewHolder.inReplyToQuote().getLayoutParams();
1055 qlayoutParams.width = (int) (scaledW - (22 * metrics.density));
1056 viewHolder.messageBody().setLayoutParams(qlayoutParams);
1057 }
1058 }
1059
1060 private void toggleWhisperInfo(
1061 final BubbleMessageItemViewHolder viewHolder,
1062 final Message message,
1063 final BubbleColor bubbleColor) {
1064 if (message.isPrivateMessage()) {
1065 final String privateMarker;
1066 if (message.getStatus() <= Message.STATUS_RECEIVED) {
1067 privateMarker = activity.getString(R.string.private_message);
1068 } else {
1069 Jid cp = message.getCounterpart();
1070 privateMarker =
1071 activity.getString(
1072 R.string.private_message_to,
1073 Strings.nullToEmpty(cp == null ? null : cp.getResource()));
1074 }
1075 final SpannableString body = new SpannableString(privateMarker);
1076 body.setSpan(
1077 new ForegroundColorSpan(
1078 bubbleToOnSurfaceVariant(viewHolder.messageBody(), bubbleColor)),
1079 0,
1080 privateMarker.length(),
1081 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1082 body.setSpan(
1083 new StyleSpan(Typeface.BOLD),
1084 0,
1085 privateMarker.length(),
1086 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1087 viewHolder.messageBody().setText(body);
1088 viewHolder.messageBody().setTypeface(null, Typeface.NORMAL);
1089 viewHolder.messageBody().setVisibility(View.VISIBLE);
1090 } else {
1091 viewHolder.messageBody().setVisibility(View.GONE);
1092 }
1093 }
1094
1095 private void loadMoreMessages(final Conversation conversation) {
1096 conversation.setLastClearHistory(0, null);
1097 activity.xmppConnectionService.updateConversation(conversation);
1098 conversation.setHasMessagesLeftOnServer(true);
1099 conversation.setFirstMamReference(null);
1100 long timestamp = conversation.getLastMessageTransmitted().getTimestamp();
1101 if (timestamp == 0) {
1102 timestamp = System.currentTimeMillis();
1103 }
1104 conversation.messagesLoaded.set(true);
1105 MessageArchiveService.Query query =
1106 activity.xmppConnectionService
1107 .getMessageArchiveService()
1108 .query(conversation, new MamReference(0), timestamp, false);
1109 if (query != null) {
1110 Toast.makeText(activity, R.string.fetching_history_from_server, Toast.LENGTH_LONG)
1111 .show();
1112 } else {
1113 Toast.makeText(
1114 activity,
1115 R.string.not_fetching_history_retention_period,
1116 Toast.LENGTH_SHORT)
1117 .show();
1118 }
1119 }
1120
1121 private MessageItemViewHolder getViewHolder(
1122 final View view, final @NonNull ViewGroup parent, final int type) {
1123 if (view != null && view.getTag() instanceof MessageItemViewHolder messageItemViewHolder) {
1124 return messageItemViewHolder;
1125 } else {
1126 final MessageItemViewHolder viewHolder =
1127 switch (type) {
1128 case RTP_SESSION ->
1129 new RtpSessionMessageItemViewHolder(
1130 DataBindingUtil.inflate(
1131 LayoutInflater.from(parent.getContext()),
1132 R.layout.item_message_rtp_session,
1133 parent,
1134 false));
1135 case DATE_SEPARATOR ->
1136 new DateSeperatorMessageItemViewHolder(
1137 DataBindingUtil.inflate(
1138 LayoutInflater.from(parent.getContext()),
1139 R.layout.item_message_date_bubble,
1140 parent,
1141 false));
1142 case STATUS ->
1143 new StatusMessageItemViewHolder(
1144 DataBindingUtil.inflate(
1145 LayoutInflater.from(parent.getContext()),
1146 R.layout.item_message_status,
1147 parent,
1148 false));
1149 case END ->
1150 new EndBubbleMessageItemViewHolder(
1151 DataBindingUtil.inflate(
1152 LayoutInflater.from(parent.getContext()),
1153 R.layout.item_message_end,
1154 parent,
1155 false));
1156 case START ->
1157 new StartBubbleMessageItemViewHolder(
1158 DataBindingUtil.inflate(
1159 LayoutInflater.from(parent.getContext()),
1160 R.layout.item_message_start,
1161 parent,
1162 false));
1163 default -> throw new AssertionError("Unable to create ViewHolder for type");
1164 };
1165 viewHolder.itemView.setTag(viewHolder);
1166 return viewHolder;
1167 }
1168 }
1169
1170 @NonNull
1171 @Override
1172 public View getView(final int position, final View view, final @NonNull ViewGroup parent) {
1173 final Message message = getItem(position);
1174 final int type = getItemViewType(message, bubbleDesign.alignStart);
1175 final MessageItemViewHolder viewHolder = getViewHolder(view, parent, type);
1176
1177 if (type == DATE_SEPARATOR
1178 && viewHolder instanceof DateSeperatorMessageItemViewHolder messageItemViewHolder) {
1179 return render(message, messageItemViewHolder);
1180 }
1181
1182 if (type == RTP_SESSION
1183 && viewHolder instanceof RtpSessionMessageItemViewHolder messageItemViewHolder) {
1184 return render(message, messageItemViewHolder);
1185 }
1186
1187 if (type == STATUS
1188 && viewHolder instanceof StatusMessageItemViewHolder messageItemViewHolder) {
1189 return render(message, messageItemViewHolder);
1190 }
1191
1192 if ((type == END || type == START)
1193 && viewHolder instanceof BubbleMessageItemViewHolder messageItemViewHolder) {
1194 return render(position, message, messageItemViewHolder);
1195 }
1196
1197 throw new AssertionError();
1198 }
1199
1200 private View render(
1201 final int position,
1202 final Message message,
1203 final BubbleMessageItemViewHolder viewHolder) {
1204 final boolean omemoEncryption = message.getEncryption() == Message.ENCRYPTION_AXOLOTL;
1205 final boolean isInValidSession =
1206 message.isValidInSession() && (!omemoEncryption || message.isTrusted());
1207 final Conversational conversation = message.getConversation();
1208 final Account account = conversation.getAccount();
1209 final List<Element> commands = message.getCommands();
1210
1211 viewHolder.linkDescriptions().setOnItemClickListener((adapter, v, pos, id) -> {
1212 final var desc = (Element) adapter.getItemAtPosition(pos);
1213 var url = desc.findChildContent("url", "https://ogp.me/ns#");
1214 // should we prefer about? Maybe, it's the real original link, but it's not what we show the user
1215 if (url == null || url.length() < 1) url = desc.getAttribute("{http://www.w3.org/1999/02/22-rdf-syntax-ns#}about");
1216 if (url == null || url.length() < 1) return;
1217 new FixedURLSpan(url).onClick(v);
1218 });
1219
1220 if (viewHolder.messageBody() != null) {
1221 viewHolder.messageBody().setCustomSelectionActionModeCallback(new MessageTextActionModeCallback(this, viewHolder.messageBody()));
1222 }
1223
1224 if (viewHolder.time() != null) {
1225 if (message.isAttention()) {
1226 viewHolder.time().setTypeface(null, Typeface.BOLD);
1227 } else {
1228 viewHolder.time().setTypeface(null, Typeface.NORMAL);
1229 }
1230 }
1231
1232 final var black = MaterialColors.getColor(viewHolder.root(), com.google.android.material.R.attr.colorSecondaryContainer) == viewHolder.root().getContext().getColor(android.R.color.black);
1233 final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
1234 final boolean received = message.getStatus() == Message.STATUS_RECEIVED;
1235 final BubbleColor bubbleColor;
1236 if (received) {
1237 if (isInValidSession) {
1238 bubbleColor = colorfulBackground || black ? BubbleColor.SECONDARY : BubbleColor.SURFACE;
1239 } else {
1240 bubbleColor = BubbleColor.WARNING;
1241 }
1242 } else {
1243 if (!colorfulBackground && black) {
1244 bubbleColor = BubbleColor.SECONDARY;
1245 } else {
1246 bubbleColor = colorfulBackground ? BubbleColor.TERTIARY : BubbleColor.SURFACE_HIGH;
1247 }
1248 }
1249
1250 if (viewHolder.threadIdenticon() != null) {
1251 viewHolder.threadIdenticon().setVisibility(View.GONE);
1252 final Element thread = message.getThread();
1253 if (thread != null) {
1254 final String threadId = thread.getContent();
1255 if (threadId != null) {
1256 final var roles = MaterialColors.getColorRoles(activity, UIHelper.getColorForName(threadId));
1257 viewHolder.threadIdenticon().setVisibility(View.VISIBLE);
1258 viewHolder.threadIdenticon().setColor(roles.getAccent());
1259 viewHolder.threadIdenticon().setHash(UIHelper.identiconHash(threadId));
1260 }
1261 }
1262 }
1263
1264 final var mergeIntoTop = mergeIntoTop(position, message);
1265 final var mergeIntoBottom = mergeIntoBottom(position, message);
1266 final var showAvatar =
1267 bubbleDesign.showAvatars
1268 || (viewHolder instanceof StartBubbleMessageItemViewHolder
1269 && message.getConversation().getMode() == Conversation.MODE_MULTI);
1270 setBubblePadding(viewHolder.root(), mergeIntoTop, mergeIntoBottom);
1271 if (showAvatar) {
1272 final var requiresAvatar =
1273 viewHolder instanceof StartBubbleMessageItemViewHolder
1274 ? !mergeIntoTop
1275 : !mergeIntoBottom;
1276 setRequiresAvatar(viewHolder, requiresAvatar);
1277 AvatarWorkerTask.loadAvatar(message, viewHolder.contactPicture(), R.dimen.avatar);
1278 } else {
1279 viewHolder.contactPicture().setVisibility(View.GONE);
1280 }
1281 setAvatarDistance(viewHolder.messageBox(), viewHolder.getClass(), showAvatar);
1282 //viewHolder.messageBox().setClipToOutline(true); remove to show tails
1283
1284 resetClickListener(viewHolder.messageBox(), viewHolder.messageBody());
1285
1286 viewHolder.messageBox().setOnClickListener(v -> {
1287 if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1288 MessageAdapter.this.mOnMessageBoxClickedListener
1289 .onContactPictureClicked(message);
1290 }
1291 });
1292 SwipeDetector swipeDetector = new SwipeDetector((action) -> {
1293 if (action == SwipeDetector.Action.LR && MessageAdapter.this.mOnMessageBoxSwipedListener != null) {
1294 MessageAdapter.this.mOnMessageBoxSwipedListener.onContactPictureClicked(message);
1295 }
1296 });
1297 viewHolder.messageBox().setOnTouchListener(swipeDetector);
1298 viewHolder.image().setOnTouchListener(swipeDetector);
1299 viewHolder.time().setOnTouchListener(swipeDetector);
1300
1301 // Treat touch-up as click so we don't have to touch twice
1302 // (touch twice is because it's waiting to see if you double-touch for text selection)
1303 viewHolder.messageBody().setOnTouchListener((v, event) -> {
1304 if (event.getAction() == MotionEvent.ACTION_UP) {
1305 if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1306 MessageAdapter.this.mOnMessageBoxClickedListener
1307 .onContactPictureClicked(message);
1308 }
1309 }
1310
1311 swipeDetector.onTouch(v, event);
1312
1313 return false;
1314 });
1315 viewHolder.messageBody().setOnClickListener(v -> {
1316 if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1317 MessageAdapter.this.mOnMessageBoxClickedListener
1318 .onContactPictureClicked(message);
1319 }
1320 });
1321 viewHolder.messageBody().setAccessibilityDelegate(null);
1322
1323 viewHolder
1324 .contactPicture()
1325 .setOnClickListener(
1326 v -> {
1327 if (MessageAdapter.this.mOnContactPictureClickedListener != null) {
1328 MessageAdapter.this.mOnContactPictureClickedListener
1329 .onContactPictureClicked(message);
1330 }
1331 });
1332 viewHolder
1333 .contactPicture()
1334 .setOnLongClickListener(
1335 v -> {
1336 if (MessageAdapter.this.mOnContactPictureLongClickedListener != null) {
1337 MessageAdapter.this.mOnContactPictureLongClickedListener
1338 .onContactPictureLongClicked(v, message);
1339 return true;
1340 } else {
1341 return false;
1342 }
1343 });
1344
1345 boolean footerWrap = false;
1346 final Transferable transferable = message.getTransferable();
1347 final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
1348
1349 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));
1350 if (muted) {
1351 // Muted MUC participant
1352 displayInfoMessage(viewHolder, "Muted", bubbleColor);
1353 } else if (unInitiatedButKnownSize || message.isDeleted() || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING)) {
1354 if (unInitiatedButKnownSize || (message.isDeleted() && message.getModerated() == null) || transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER) {
1355 displayDownloadableMessage(viewHolder, message, activity.getString(R.string.download_x_file, UIHelper.getFileDescriptionString(activity, message)), bubbleColor);
1356 } else if (transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER_CHECK_FILESIZE) {
1357 displayDownloadableMessage(viewHolder, message, activity.getString(R.string.check_x_filesize, UIHelper.getFileDescriptionString(activity, message)), bubbleColor);
1358 } else {
1359 displayInfoMessage(viewHolder, UIHelper.getMessagePreview(activity.xmppConnectionService, message).first, bubbleColor);
1360 }
1361 } else if (message.isFileOrImage()
1362 && message.getEncryption() != Message.ENCRYPTION_PGP
1363 && message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED) {
1364 if (message.getFileParams().width > 0 && message.getFileParams().height > 0) {
1365 displayMediaPreviewMessage(viewHolder, message, bubbleColor);
1366 } else if (message.getFileParams().runtime > 0) {
1367 displayAudioMessage(viewHolder, message, bubbleColor);
1368 } else if ("application/webxdc+zip".equals(message.getFileParams().getMediaType()) && message.getConversation() instanceof Conversation && message.getThread() != null && !message.getFileParams().getCids().isEmpty()) {
1369 displayWebxdcMessage(viewHolder, message, bubbleColor);
1370 } else {
1371 displayOpenableMessage(viewHolder, message, bubbleColor);
1372 }
1373 } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
1374 if (account.isPgpDecryptionServiceConnected()) {
1375 if (conversation instanceof Conversation
1376 && !account.hasPendingPgpIntent((Conversation) conversation)) {
1377 displayInfoMessage(
1378 viewHolder,
1379 activity.getString(R.string.message_decrypting),
1380 bubbleColor);
1381 } else {
1382 displayInfoMessage(
1383 viewHolder, activity.getString(R.string.pgp_message), bubbleColor);
1384 }
1385 } else {
1386 displayInfoMessage(
1387 viewHolder, activity.getString(R.string.install_openkeychain), bubbleColor);
1388 viewHolder.messageBox().setOnClickListener(this::promptOpenKeychainInstall);
1389 viewHolder.messageBody().setOnClickListener(this::promptOpenKeychainInstall);
1390 }
1391 } else if (message.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) {
1392 displayInfoMessage(
1393 viewHolder, activity.getString(R.string.decryption_failed), bubbleColor);
1394 } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE) {
1395 displayInfoMessage(
1396 viewHolder,
1397 activity.getString(R.string.not_encrypted_for_this_device),
1398 bubbleColor);
1399 } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_FAILED) {
1400 displayInfoMessage(
1401 viewHolder, activity.getString(R.string.omemo_decryption_failed), bubbleColor);
1402 } else {
1403 if (message.wholeIsKnownURI() != null) {
1404 displayURIMessage(viewHolder, message, bubbleColor);
1405 } else if (message.isGeoUri()) {
1406 displayLocationMessage(viewHolder, message, bubbleColor);
1407 } else if (message.treatAsDownloadable()) {
1408 try {
1409 final URI uri = message.getOob();
1410 displayDownloadableMessage(viewHolder,
1411 message,
1412 activity.getString(
1413 R.string.check_x_filesize_on_host,
1414 UIHelper.getFileDescriptionString(activity, message),
1415 uri.getHost()),
1416 bubbleColor);
1417 } catch (Exception e) {
1418 displayDownloadableMessage(
1419 viewHolder,
1420 message,
1421 activity.getString(
1422 R.string.check_x_filesize,
1423 UIHelper.getFileDescriptionString(activity, message)),
1424 bubbleColor);
1425 }
1426 } else if (message.bodyIsOnlyEmojis() && message.getType() != Message.TYPE_PRIVATE) {
1427 displayEmojiMessage(viewHolder, message, bubbleColor);
1428 } else {
1429 displayTextMessage(viewHolder, message, bubbleColor);
1430 }
1431 }
1432
1433 if (!black && viewHolder.image().getLayoutParams().width > metrics.density * 110) {
1434 footerWrap = true;
1435 }
1436
1437 viewHolder.messageBoxInner().setMinimumWidth(footerWrap ? (int) (110 * metrics.density) : 0);
1438 LinearLayout.LayoutParams statusParams = (LinearLayout.LayoutParams) viewHolder.statusLine().getLayoutParams();
1439 statusParams.width = footerWrap ? ViewGroup.LayoutParams.MATCH_PARENT : ViewGroup.LayoutParams.WRAP_CONTENT;
1440 viewHolder.statusLine().setLayoutParams(statusParams);
1441
1442 final Function<Reaction, GetThumbnailForCid> reactionThumbnailer = (r) -> new Thumbnailer(conversation.getAccount(), r, conversation.canInferPresence());
1443 if (received) {
1444 if (!muted && commands != null && conversation instanceof Conversation) {
1445 CommandButtonAdapter adapter = new CommandButtonAdapter(activity);
1446 adapter.addAll(commands);
1447 viewHolder.commandsList().setAdapter(adapter);
1448 viewHolder.commandsList().setVisibility(View.VISIBLE);
1449 viewHolder.commandsList().setOnItemClickListener((p, v, pos, id) -> {
1450 final Element command = adapter.getItem(pos);
1451 activity.startCommand(conversation.getAccount(), command.getAttributeAsJid("jid"), command.getAttribute("node"));
1452 });
1453 } else {
1454 // It's unclear if we can set this to null...
1455 ListAdapter adapter = viewHolder.commandsList().getAdapter();
1456 if (adapter instanceof ArrayAdapter) {
1457 ((ArrayAdapter<?>) adapter).clear();
1458 }
1459 viewHolder.commandsList().setVisibility(View.GONE);
1460 viewHolder.commandsList().setOnItemClickListener(null);
1461 }
1462 }
1463
1464 setBackgroundTint(viewHolder.messageBox(), bubbleColor);
1465 setTextColor(viewHolder.messageBody(), bubbleColor);
1466 viewHolder.messageBody().setLinkTextColor(bubbleToOnSurfaceColor(viewHolder.messageBody(), bubbleColor));
1467
1468 if (received && viewHolder instanceof StartBubbleMessageItemViewHolder startViewHolder) {
1469 setTextColor(startViewHolder.encryption(), bubbleColor);
1470 if (isInValidSession) {
1471 startViewHolder.encryption().setVisibility(View.GONE);
1472 } else {
1473 startViewHolder.encryption().setVisibility(View.VISIBLE);
1474 if (omemoEncryption && !message.isTrusted()) {
1475 startViewHolder.encryption().setText(R.string.not_trusted);
1476 } else {
1477 startViewHolder
1478 .encryption()
1479 .setText(CryptoHelper.encryptionTypeToText(message.getEncryption()));
1480 }
1481 }
1482 final var aggregatedReactions = conversation instanceof Conversation ? ((Conversation) conversation).aggregatedReactionsFor(message, reactionThumbnailer) : message.getAggregatedReactions();
1483 BindingAdapters.setReactionsOnReceived(
1484 viewHolder.reactions(),
1485 aggregatedReactions,
1486 reactions -> sendReactions(message, reactions),
1487 emoji -> showDetailedReaction(message, emoji),
1488 emoji -> sendCustomReaction(message, emoji),
1489 reaction -> removeCustomReaction(conversation, reaction),
1490 () -> addReaction(message));
1491 } else {
1492 if (viewHolder instanceof StartBubbleMessageItemViewHolder startViewHolder) {
1493 startViewHolder.encryption().setVisibility(View.GONE);
1494 }
1495 BindingAdapters.setReactionsOnSent(
1496 viewHolder.reactions(),
1497 message.getAggregatedReactions(),
1498 reactions -> sendReactions(message, reactions),
1499 emoji -> showDetailedReaction(message, emoji));
1500 }
1501
1502 var subject = message.getSubject();
1503 if (subject == null && message.getThread() != null) {
1504 final var thread = ((Conversation) message.getConversation()).getThread(message.getThread().getContent());
1505 if (thread != null) subject = thread.getSubject();
1506 }
1507 if (muted || subject == null) {
1508 viewHolder.subject().setVisibility(View.GONE);
1509 } else {
1510 viewHolder.subject().setVisibility(View.VISIBLE);
1511 viewHolder.subject().setText(subject);
1512 }
1513
1514 if (message.getInReplyTo() == null) {
1515 viewHolder.inReplyToBox().setVisibility(View.GONE);
1516 } else {
1517 viewHolder.inReplyToBox().setVisibility(View.VISIBLE);
1518 viewHolder.inReplyTo().setText(UIHelper.getMessageDisplayName(message.getInReplyTo()));
1519 viewHolder.inReplyTo().setOnClickListener((v) -> mConversationFragment.jumpTo(message.getInReplyTo()));
1520 viewHolder.inReplyToQuote().setOnClickListener((v) -> mConversationFragment.jumpTo(message.getInReplyTo()));
1521 setTextColor(viewHolder.inReplyTo(), bubbleColor);
1522 }
1523
1524 if (appSettings.showLinkPreviews()) {
1525 final var descriptions = message.getLinkDescriptions();
1526 viewHolder.linkDescriptions().setAdapter(new ArrayAdapter<>(activity, 0, descriptions) {
1527 @Override
1528 public View getView(int position, View view, @NonNull ViewGroup parent) {
1529 final LinkDescriptionBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()), R.layout.link_description, parent, false);
1530 binding.title.setText(getItem(position).findChildContent("title", "https://ogp.me/ns#"));
1531 binding.description.setText(getItem(position).findChildContent("description", "https://ogp.me/ns#"));
1532 binding.url.setText(getItem(position).findChildContent("url", "https://ogp.me/ns#"));
1533 final var video = getItem(position).findChildContent("video", "https://ogp.me/ns#");
1534 if (video != null && video.length() > 0) {
1535 binding.playButton.setVisibility(View.VISIBLE);
1536 binding.playButton.setOnClickListener((v) -> {
1537 new FixedURLSpan(video).onClick(v);
1538 });
1539 }
1540 return binding.getRoot();
1541 }
1542 });
1543 Util.justifyListViewHeightBasedOnChildren(viewHolder.linkDescriptions(), (int)(metrics.density * 100), true);
1544 }
1545
1546 displayStatus(viewHolder, message, bubbleColor);
1547
1548 viewHolder.messageBody().setAccessibilityDelegate(new View.AccessibilityDelegate() {
1549 @Override
1550 public void sendAccessibilityEvent(View host, int eventType) {
1551 super.sendAccessibilityEvent(host, eventType);
1552 if (eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) {
1553 if (viewHolder.messageBody().hasSelection()) {
1554 selectionUuid = message.getUuid();
1555 } else if (message.getUuid() != null && message.getUuid().equals(selectionUuid)) {
1556 selectionUuid = null;
1557 }
1558 }
1559 }
1560 });
1561
1562 return viewHolder.root();
1563 }
1564
1565 private View render(
1566 final Message message, final DateSeperatorMessageItemViewHolder viewHolder) {
1567 final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
1568 if (UIHelper.today(message.getTimeSent())) {
1569 viewHolder.binding.messageBody.setText(R.string.today);
1570 } else if (UIHelper.yesterday(message.getTimeSent())) {
1571 viewHolder.binding.messageBody.setText(R.string.yesterday);
1572 } else {
1573 viewHolder.binding.messageBody.setText(
1574 DateUtils.formatDateTime(
1575 activity,
1576 message.getTimeSent(),
1577 DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR));
1578 }
1579 if (colorfulBackground) {
1580 setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.PRIMARY);
1581 setTextColor(viewHolder.binding.messageBody, BubbleColor.PRIMARY);
1582 } else {
1583 setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.SURFACE_HIGH);
1584 setTextColor(viewHolder.binding.messageBody, BubbleColor.SURFACE_HIGH);
1585 }
1586 return viewHolder.binding.getRoot();
1587 }
1588
1589 private View render(final Message message, final RtpSessionMessageItemViewHolder viewHolder) {
1590 final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
1591 final boolean received = message.getStatus() <= Message.STATUS_RECEIVED;
1592 final RtpSessionStatus rtpSessionStatus = RtpSessionStatus.of(message.getBody());
1593 final long duration = rtpSessionStatus.duration;
1594 if (received) {
1595 if (duration > 0) {
1596 viewHolder.binding.messageBody.setText(
1597 activity.getString(
1598 R.string.incoming_call_duration_timestamp,
1599 TimeFrameUtils.resolve(activity, duration),
1600 UIHelper.readableTimeDifferenceFull(
1601 activity, message.getTimeSent())));
1602 } else if (rtpSessionStatus.successful) {
1603 viewHolder.binding.messageBody.setText(R.string.incoming_call);
1604 } else {
1605 viewHolder.binding.messageBody.setText(
1606 activity.getString(
1607 R.string.missed_call_timestamp,
1608 UIHelper.readableTimeDifferenceFull(
1609 activity, message.getTimeSent())));
1610 }
1611 } else {
1612 if (duration > 0) {
1613 viewHolder.binding.messageBody.setText(
1614 activity.getString(
1615 R.string.outgoing_call_duration_timestamp,
1616 TimeFrameUtils.resolve(activity, duration),
1617 UIHelper.readableTimeDifferenceFull(
1618 activity, message.getTimeSent())));
1619 } else {
1620 viewHolder.binding.messageBody.setText(
1621 activity.getString(
1622 R.string.outgoing_call_timestamp,
1623 UIHelper.readableTimeDifferenceFull(
1624 activity, message.getTimeSent())));
1625 }
1626 }
1627 if (colorfulBackground) {
1628 setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.SECONDARY);
1629 setTextColor(viewHolder.binding.messageBody, BubbleColor.SECONDARY);
1630 setImageTint(viewHolder.binding.indicatorReceived, BubbleColor.SECONDARY);
1631 } else {
1632 setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.SURFACE_HIGH);
1633 setTextColor(viewHolder.binding.messageBody, BubbleColor.SURFACE_HIGH);
1634 setImageTint(viewHolder.binding.indicatorReceived, BubbleColor.SURFACE_HIGH);
1635 }
1636 viewHolder.binding.indicatorReceived.setImageResource(
1637 RtpSessionStatus.getDrawable(received, rtpSessionStatus.successful));
1638 return viewHolder.binding.getRoot();
1639 }
1640
1641 private View render(final Message message, final StatusMessageItemViewHolder viewHolder) {
1642 final var conversation = message.getConversation();
1643 if ("LOAD_MORE".equals(message.getBody())) {
1644 viewHolder.binding.statusMessage.setVisibility(View.GONE);
1645 viewHolder.binding.messagePhoto.setVisibility(View.GONE);
1646 viewHolder.binding.loadMoreMessages.setVisibility(View.VISIBLE);
1647 viewHolder.binding.loadMoreMessages.setOnClickListener(
1648 v -> loadMoreMessages((Conversation) message.getConversation()));
1649 } else {
1650 viewHolder.binding.statusMessage.setVisibility(View.VISIBLE);
1651 viewHolder.binding.loadMoreMessages.setVisibility(View.GONE);
1652 viewHolder.binding.statusMessage.setText(message.getBody());
1653 boolean showAvatar;
1654 if (conversation.getMode() == Conversation.MODE_SINGLE) {
1655 showAvatar = true;
1656 AvatarWorkerTask.loadAvatar(
1657 message, viewHolder.binding.messagePhoto, R.dimen.avatar_on_status_message);
1658 } else if (message.getCounterpart() != null
1659 || message.getTrueCounterpart() != null
1660 || (message.getCounterparts() != null
1661 && !message.getCounterparts().isEmpty())) {
1662 showAvatar = true;
1663 AvatarWorkerTask.loadAvatar(
1664 message, viewHolder.binding.messagePhoto, R.dimen.avatar_on_status_message);
1665 } else {
1666 showAvatar = false;
1667 }
1668 if (showAvatar) {
1669 viewHolder.binding.messagePhoto.setAlpha(0.5f);
1670 viewHolder.binding.messagePhoto.setVisibility(View.VISIBLE);
1671 } else {
1672 viewHolder.binding.messagePhoto.setVisibility(View.GONE);
1673 }
1674 }
1675 return viewHolder.binding.getRoot();
1676 }
1677
1678 private void setAvatarDistance(
1679 final LinearLayout messageBox,
1680 final Class<? extends BubbleMessageItemViewHolder> clazz,
1681 final boolean showAvatar) {
1682 final ViewGroup.MarginLayoutParams layoutParams =
1683 (ViewGroup.MarginLayoutParams) messageBox.getLayoutParams();
1684 if (false) { // no need for space since the shape has space inside it for tails
1685 final var resources = messageBox.getResources();
1686 if (clazz == StartBubbleMessageItemViewHolder.class) {
1687 layoutParams.setMarginStart(
1688 resources.getDimensionPixelSize(R.dimen.bubble_avatar_distance));
1689 layoutParams.setMarginEnd(0);
1690 } else if (clazz == EndBubbleMessageItemViewHolder.class) {
1691 layoutParams.setMarginStart(0);
1692 layoutParams.setMarginEnd(
1693 resources.getDimensionPixelSize(R.dimen.bubble_avatar_distance));
1694 } else {
1695 throw new AssertionError("Avatar distances are not available on this view type");
1696 }
1697 } else {
1698 layoutParams.setMarginStart(0);
1699 layoutParams.setMarginEnd(0);
1700 }
1701 messageBox.setLayoutParams(layoutParams);
1702 }
1703
1704 private void setBubblePadding(
1705 final ConstraintLayout root,
1706 final boolean mergeIntoTop,
1707 final boolean mergeIntoBottom) {
1708 final var resources = root.getResources();
1709 final var horizontal = resources.getDimensionPixelSize(R.dimen.bubble_horizontal_padding);
1710 final int top =
1711 resources.getDimensionPixelSize(
1712 mergeIntoTop
1713 ? R.dimen.bubble_vertical_padding_minimum
1714 : R.dimen.bubble_vertical_padding);
1715 final int bottom =
1716 resources.getDimensionPixelSize(
1717 mergeIntoBottom
1718 ? R.dimen.bubble_vertical_padding_minimum
1719 : R.dimen.bubble_vertical_padding);
1720 root.setPadding(horizontal, top, horizontal, bottom);
1721 }
1722
1723 private void setRequiresAvatar(
1724 final BubbleMessageItemViewHolder viewHolder, final boolean requiresAvatar) {
1725 final var layoutParams = viewHolder.contactPicture().getLayoutParams();
1726 if (requiresAvatar) {
1727 final var resources = viewHolder.contactPicture().getResources();
1728 final var avatarSize = resources.getDimensionPixelSize(R.dimen.bubble_avatar_size);
1729 layoutParams.height = avatarSize;
1730 viewHolder.contactPicture().setVisibility(View.VISIBLE);
1731 viewHolder.messageBox().setMinimumHeight(avatarSize);
1732 } else {
1733 layoutParams.height = 0;
1734 viewHolder.contactPicture().setVisibility(View.INVISIBLE);
1735 viewHolder.messageBox().setMinimumHeight(0);
1736 }
1737 viewHolder.contactPicture().setLayoutParams(layoutParams);
1738 }
1739
1740 private boolean mergeIntoTop(final int position, final Message message) {
1741 if (position < 0) {
1742 return false;
1743 }
1744 final var top = getItem(position - 1);
1745 return merge(top, message);
1746 }
1747
1748 private boolean mergeIntoBottom(final int position, final Message message) {
1749 final Message bottom;
1750 try {
1751 bottom = getItem(position + 1);
1752 } catch (final IndexOutOfBoundsException e) {
1753 return false;
1754 }
1755 return merge(message, bottom);
1756 }
1757
1758 private static boolean merge(final Message a, final Message b) {
1759 if (getItemViewType(a, false) != getItemViewType(b, false)) {
1760 return false;
1761 }
1762 final var receivedA = a.getStatus() == Message.STATUS_RECEIVED;
1763 final var receivedB = b.getStatus() == Message.STATUS_RECEIVED;
1764 if (receivedA != receivedB) {
1765 return false;
1766 }
1767 if (a.getConversation().getMode() == Conversation.MODE_MULTI
1768 && a.getStatus() == Message.STATUS_RECEIVED) {
1769 final var occupantIdA = a.getOccupantId();
1770 final var occupantIdB = b.getOccupantId();
1771 if (occupantIdA != null && occupantIdB != null) {
1772 if (!occupantIdA.equals(occupantIdB)) {
1773 return false;
1774 }
1775 }
1776 final var counterPartA = a.getCounterpart();
1777 final var counterPartB = b.getCounterpart();
1778 if (counterPartA == null || !counterPartA.equals(counterPartB)) {
1779 return false;
1780 }
1781 }
1782 return b.getTimeSent() - a.getTimeSent() <= Config.MESSAGE_MERGE_WINDOW;
1783 }
1784
1785 private boolean showDetailedReaction(final Message message, Map.Entry<EmojiSearch.Emoji, Collection<Reaction>> reaction) {
1786 final var c = message.getConversation();
1787 if (c instanceof Conversation conversation && c.getMode() == Conversational.MODE_MULTI) {
1788 final var reactions = reaction.getValue();
1789 final var mucOptions = conversation.getMucOptions();
1790 final var users = mucOptions.findUsers(reactions);
1791 if (users.isEmpty()) {
1792 return true;
1793 }
1794 final MaterialAlertDialogBuilder dialogBuilder =
1795 new MaterialAlertDialogBuilder(activity);
1796 dialogBuilder.setTitle(reaction.getKey().toString());
1797 dialogBuilder.setMessage(UIHelper.concatNames(users));
1798 dialogBuilder.create().show();
1799 return true;
1800 } else {
1801 return false;
1802 }
1803 }
1804
1805 private void sendReactions(final Message message, final Collection<String> reactions) {
1806 if (!message.isPrivateMessage() && activity.xmppConnectionService.sendReactions(message, reactions)) {
1807 return;
1808 }
1809 Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
1810 }
1811
1812 private void sendCustomReaction(final Message inReplyTo, final EmojiSearch.CustomEmoji emoji) {
1813 final var message = inReplyTo.reply();
1814 message.appendBody(emoji.toInsert());
1815 Message.configurePrivateMessage(message);
1816 new Thread(() -> activity.xmppConnectionService.sendMessage(message)).start();
1817 }
1818
1819 private void removeCustomReaction(final Conversational conversation, final Reaction reaction) {
1820 if (!(conversation instanceof Conversation)) {
1821 Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
1822 return;
1823 }
1824
1825 final var message = new Message(conversation, " ", ((Conversation) conversation).getNextEncryption());
1826 final var envelope = ((Conversation) conversation).findMessageWithUuidOrRemoteId(reaction.envelopeId);
1827 if (envelope != null) {
1828 ((Conversation) conversation).remove(envelope);
1829 message.addPayload(envelope.getReply());
1830 message.getOrMakeHtml();
1831 message.putEdited(reaction.envelopeId, envelope.getServerMsgId());
1832 } else {
1833 message.putEdited(reaction.envelopeId, null);
1834 }
1835
1836 new Thread(() -> activity.xmppConnectionService.sendMessage(message)).start();
1837 }
1838
1839 private void addReaction(final Message message) {
1840 activity.addReaction(
1841 message,
1842 reactions -> {
1843 if (activity.xmppConnectionService.sendReactions(message, reactions)) {
1844 return;
1845 }
1846 Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG)
1847 .show();
1848 });
1849 }
1850
1851 private void promptOpenKeychainInstall(View view) {
1852 activity.showInstallPgpDialog();
1853 }
1854
1855 public FileBackend getFileBackend() {
1856 return activity.xmppConnectionService.getFileBackend();
1857 }
1858
1859 public void stopAudioPlayer() {
1860 audioPlayer.stop();
1861 }
1862
1863 public void unregisterListenerInAudioPlayer() {
1864 audioPlayer.unregisterListener();
1865 }
1866
1867 public void startStopPending() {
1868 audioPlayer.startStopPending();
1869 }
1870
1871 public void openDownloadable(Message message) {
1872 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
1873 && ContextCompat.checkSelfPermission(
1874 activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)
1875 != PackageManager.PERMISSION_GRANTED) {
1876 ConversationFragment.registerPendingMessage(activity, message);
1877 ActivityCompat.requestPermissions(
1878 activity,
1879 new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE},
1880 ConversationsActivity.REQUEST_OPEN_MESSAGE);
1881 return;
1882 }
1883 final DownloadableFile file =
1884 activity.xmppConnectionService.getFileBackend().getFile(message);
1885 final var fp = message.getFileParams();
1886 final var name = fp == null ? null : fp.getName();
1887 final var displayName = name == null ? file.getName() : name;
1888 ViewUtil.view(activity, file, displayName);
1889 }
1890
1891 private void showLocation(Message message) {
1892 for (Intent intent : GeoHelper.createGeoIntentsFromMessage(activity, message)) {
1893 if (intent.resolveActivity(getContext().getPackageManager()) != null) {
1894 getContext().startActivity(intent);
1895 return;
1896 }
1897 }
1898 Toast.makeText(
1899 activity,
1900 R.string.no_application_found_to_display_location,
1901 Toast.LENGTH_SHORT)
1902 .show();
1903 }
1904
1905 public void updatePreferences() {
1906 this.bubbleDesign =
1907 new BubbleDesign(
1908 appSettings.isColorfulChatBubbles(),
1909 appSettings.isAlignStart(),
1910 appSettings.isLargeFont(),
1911 appSettings.isShowAvatars());
1912 }
1913
1914 public void setHighlightedTerm(List<String> terms) {
1915 this.highlightedTerm = terms == null ? null : StylingHelper.filterHighlightedWords(terms);
1916 }
1917
1918 public interface OnContactPictureClicked {
1919 void onContactPictureClicked(Message message);
1920 }
1921
1922 public interface OnContactPictureLongClicked {
1923 void onContactPictureLongClicked(View v, Message message);
1924 }
1925
1926 public interface OnInlineImageLongClicked {
1927 boolean onInlineImageLongClicked(Cid cid);
1928 }
1929
1930 private static void setBackgroundTint(final LinearLayout view, final BubbleColor bubbleColor) {
1931 view.setBackgroundTintList(bubbleToColorStateList(view, bubbleColor));
1932 }
1933
1934 private static ColorStateList bubbleToColorStateList(
1935 final View view, final BubbleColor bubbleColor) {
1936 final @AttrRes int colorAttributeResId =
1937 switch (bubbleColor) {
1938 case SURFACE ->
1939 Activities.isNightMode(view.getContext())
1940 ? com.google.android.material.R.attr.colorSurfaceContainerHigh
1941 : com.google.android.material.R.attr.colorSurfaceContainerLow;
1942 case SURFACE_HIGH ->
1943 Activities.isNightMode(view.getContext())
1944 ? com.google.android.material.R.attr
1945 .colorSurfaceContainerHighest
1946 : com.google.android.material.R.attr.colorSurfaceContainerHigh;
1947 case PRIMARY -> com.google.android.material.R.attr.colorPrimaryContainer;
1948 case SECONDARY -> com.google.android.material.R.attr.colorSecondaryContainer;
1949 case TERTIARY -> com.google.android.material.R.attr.colorTertiaryContainer;
1950 case WARNING -> com.google.android.material.R.attr.colorErrorContainer;
1951 };
1952 return ColorStateList.valueOf(MaterialColors.getColor(view, colorAttributeResId));
1953 }
1954
1955 public static void setImageTint(final ImageView imageView, final BubbleColor bubbleColor) {
1956 ImageViewCompat.setImageTintList(
1957 imageView, bubbleToOnSurfaceColorStateList(imageView, bubbleColor));
1958 }
1959
1960 public static void setImageTintError(final ImageView imageView) {
1961 ImageViewCompat.setImageTintList(
1962 imageView,
1963 ColorStateList.valueOf(
1964 MaterialColors.getColor(
1965 imageView, com.google.android.material.R.attr.colorError)));
1966 }
1967
1968 public static void setTextColor(final TextView textView, final BubbleColor bubbleColor) {
1969 final var color = bubbleToOnSurfaceColor(textView, bubbleColor);
1970 textView.setTextColor(color);
1971 if (BubbleColor.SURFACES.contains(bubbleColor)) {
1972 textView.setLinkTextColor(
1973 MaterialColors.getColor(
1974 textView, com.google.android.material.R.attr.colorPrimary));
1975 } else {
1976 textView.setLinkTextColor(color);
1977 }
1978 }
1979
1980 private static void setTextSize(final TextView textView, final boolean largeFont) {
1981 if (largeFont) {
1982 textView.setTextAppearance(
1983 com.google.android.material.R.style.TextAppearance_Material3_BodyLarge);
1984 textView.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 18);
1985 } else {
1986 textView.setTextAppearance(
1987 com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
1988 }
1989 }
1990
1991 private static @ColorInt int bubbleToOnSurfaceVariant(
1992 final View view, final BubbleColor bubbleColor) {
1993 final @AttrRes int colorAttributeResId;
1994 if (BubbleColor.SURFACES.contains(bubbleColor)) {
1995 colorAttributeResId = com.google.android.material.R.attr.colorOnSurfaceVariant;
1996 } else {
1997 colorAttributeResId = bubbleToOnSurface(bubbleColor);
1998 }
1999 return MaterialColors.getColor(view, colorAttributeResId);
2000 }
2001
2002 private static @ColorInt int bubbleToOnSurfaceColor(
2003 final View view, final BubbleColor bubbleColor) {
2004 return MaterialColors.getColor(view, bubbleToOnSurface(bubbleColor));
2005 }
2006
2007 public static ColorStateList bubbleToOnSurfaceColorStateList(
2008 final View view, final BubbleColor bubbleColor) {
2009 return ColorStateList.valueOf(bubbleToOnSurfaceColor(view, bubbleColor));
2010 }
2011
2012 private static @AttrRes int bubbleToOnSurface(final BubbleColor bubbleColor) {
2013 return switch (bubbleColor) {
2014 case SURFACE, SURFACE_HIGH -> com.google.android.material.R.attr.colorOnSurface;
2015 case PRIMARY -> com.google.android.material.R.attr.colorOnPrimaryContainer;
2016 case SECONDARY -> com.google.android.material.R.attr.colorOnSecondaryContainer;
2017 case TERTIARY -> com.google.android.material.R.attr.colorOnTertiaryContainer;
2018 case WARNING -> com.google.android.material.R.attr.colorOnErrorContainer;
2019 };
2020 }
2021
2022 public enum BubbleColor {
2023 SURFACE,
2024 SURFACE_HIGH,
2025 PRIMARY,
2026 SECONDARY,
2027 TERTIARY,
2028 WARNING;
2029
2030 private static final Collection<BubbleColor> SURFACES =
2031 Arrays.asList(BubbleColor.SURFACE, BubbleColor.SURFACE_HIGH);
2032 }
2033
2034 private static class BubbleDesign {
2035 public final boolean colorfulChatBubbles;
2036 public final boolean alignStart;
2037 public final boolean largeFont;
2038 public final boolean showAvatars;
2039
2040 private BubbleDesign(
2041 final boolean colorfulChatBubbles,
2042 final boolean alignStart,
2043 final boolean largeFont,
2044 final boolean showAvatars) {
2045 this.colorfulChatBubbles = colorfulChatBubbles;
2046 this.alignStart = alignStart;
2047 this.largeFont = largeFont;
2048 this.showAvatars = showAvatars;
2049 }
2050 }
2051
2052 private abstract static class MessageItemViewHolder /*extends RecyclerView.ViewHolder*/ {
2053
2054 private View itemView;
2055
2056 private MessageItemViewHolder(@NonNull View itemView) {
2057 this.itemView = itemView;
2058 }
2059 }
2060
2061 private abstract static class BubbleMessageItemViewHolder extends MessageItemViewHolder {
2062
2063 private BubbleMessageItemViewHolder(@NonNull View itemView) {
2064 super(itemView);
2065 }
2066
2067 public abstract ConstraintLayout root();
2068
2069 protected abstract ImageView indicatorEdit();
2070
2071 protected abstract RelativeLayout audioPlayer();
2072
2073 protected abstract LinearLayout messageBox();
2074
2075 protected abstract MaterialButton downloadButton();
2076
2077 protected abstract ShapeableImageView image();
2078
2079 protected abstract ImageView indicatorSecurity();
2080
2081 protected abstract ImageView indicatorReceived();
2082
2083 protected abstract TextView time();
2084
2085 protected abstract TextView messageBody();
2086
2087 protected abstract ImageView contactPicture();
2088
2089 protected abstract ChipGroup reactions();
2090
2091 protected abstract ListView commandsList();
2092
2093 protected abstract View messageBoxInner();
2094
2095 protected abstract View statusLine();
2096
2097 protected abstract GithubIdenticonView threadIdenticon();
2098
2099 protected abstract ListView linkDescriptions();
2100
2101 protected abstract LinearLayout inReplyToBox();
2102
2103 protected abstract TextView inReplyTo();
2104
2105 protected abstract TextView inReplyToQuote();
2106
2107 protected abstract TextView subject();
2108 }
2109
2110 private static class StartBubbleMessageItemViewHolder extends BubbleMessageItemViewHolder {
2111
2112 private final ItemMessageStartBinding binding;
2113
2114 public StartBubbleMessageItemViewHolder(@NonNull ItemMessageStartBinding binding) {
2115 super(binding.getRoot());
2116 this.binding = binding;
2117 }
2118
2119 @Override
2120 public ConstraintLayout root() {
2121 return (ConstraintLayout) this.binding.getRoot();
2122 }
2123
2124 @Override
2125 protected ImageView indicatorEdit() {
2126 return this.binding.editIndicator;
2127 }
2128
2129 @Override
2130 protected RelativeLayout audioPlayer() {
2131 return this.binding.messageContent.audioPlayer;
2132 }
2133
2134 @Override
2135 protected LinearLayout messageBox() {
2136 return this.binding.messageBox;
2137 }
2138
2139 @Override
2140 protected MaterialButton downloadButton() {
2141 return this.binding.messageContent.downloadButton;
2142 }
2143
2144 @Override
2145 protected ShapeableImageView image() {
2146 return this.binding.messageContent.messageImage;
2147 }
2148
2149 protected ImageView indicatorSecurity() {
2150 return this.binding.securityIndicator;
2151 }
2152
2153 @Override
2154 protected ImageView indicatorReceived() {
2155 return this.binding.indicatorReceived;
2156 }
2157
2158 @Override
2159 protected TextView time() {
2160 return this.binding.messageTime;
2161 }
2162
2163 @Override
2164 protected TextView messageBody() {
2165 return this.binding.messageContent.messageBody;
2166 }
2167
2168 protected TextView encryption() {
2169 return this.binding.messageEncryption;
2170 }
2171
2172 @Override
2173 protected ImageView contactPicture() {
2174 return this.binding.messagePhoto;
2175 }
2176
2177 @Override
2178 protected ChipGroup reactions() {
2179 return this.binding.reactions;
2180 }
2181
2182 @Override
2183 protected ListView commandsList() {
2184 return this.binding.messageContent.commandsList;
2185 }
2186
2187 @Override
2188 protected View messageBoxInner() {
2189 return this.binding.messageBoxInner;
2190 }
2191
2192 @Override
2193 protected View statusLine() {
2194 return this.binding.statusLine;
2195 }
2196
2197 @Override
2198 protected GithubIdenticonView threadIdenticon() {
2199 return this.binding.threadIdenticon;
2200 }
2201
2202 @Override
2203 protected ListView linkDescriptions() {
2204 return this.binding.messageContent.linkDescriptions;
2205 }
2206
2207 @Override
2208 protected LinearLayout inReplyToBox() {
2209 return this.binding.messageContent.inReplyToBox;
2210 }
2211
2212 @Override
2213 protected TextView inReplyTo() {
2214 return this.binding.messageContent.inReplyTo;
2215 }
2216
2217 @Override
2218 protected TextView inReplyToQuote() {
2219 return this.binding.messageContent.inReplyToQuote;
2220 }
2221
2222 @Override
2223 protected TextView subject() {
2224 return this.binding.messageSubject;
2225 }
2226 }
2227
2228 private static class EndBubbleMessageItemViewHolder extends BubbleMessageItemViewHolder {
2229
2230 private final ItemMessageEndBinding binding;
2231
2232 private EndBubbleMessageItemViewHolder(@NonNull ItemMessageEndBinding binding) {
2233 super(binding.getRoot());
2234 this.binding = binding;
2235 }
2236
2237 @Override
2238 public ConstraintLayout root() {
2239 return (ConstraintLayout) this.binding.getRoot();
2240 }
2241
2242 @Override
2243 protected ImageView indicatorEdit() {
2244 return this.binding.editIndicator;
2245 }
2246
2247 @Override
2248 protected RelativeLayout audioPlayer() {
2249 return this.binding.messageContent.audioPlayer;
2250 }
2251
2252 @Override
2253 protected LinearLayout messageBox() {
2254 return this.binding.messageBox;
2255 }
2256
2257 @Override
2258 protected MaterialButton downloadButton() {
2259 return this.binding.messageContent.downloadButton;
2260 }
2261
2262 @Override
2263 protected ShapeableImageView image() {
2264 return this.binding.messageContent.messageImage;
2265 }
2266
2267 @Override
2268 protected ImageView indicatorSecurity() {
2269 return this.binding.securityIndicator;
2270 }
2271
2272 @Override
2273 protected ImageView indicatorReceived() {
2274 return this.binding.indicatorReceived;
2275 }
2276
2277 @Override
2278 protected TextView time() {
2279 return this.binding.messageTime;
2280 }
2281
2282 @Override
2283 protected TextView messageBody() {
2284 return this.binding.messageContent.messageBody;
2285 }
2286
2287 @Override
2288 protected ImageView contactPicture() {
2289 return this.binding.messagePhoto;
2290 }
2291
2292 @Override
2293 protected ChipGroup reactions() {
2294 return this.binding.reactions;
2295 }
2296
2297 @Override
2298 protected ListView commandsList() {
2299 return this.binding.messageContent.commandsList;
2300 }
2301
2302 @Override
2303 protected View messageBoxInner() {
2304 return this.binding.messageBoxInner;
2305 }
2306
2307 @Override
2308 protected View statusLine() {
2309 return this.binding.statusLine;
2310 }
2311
2312 @Override
2313 protected GithubIdenticonView threadIdenticon() {
2314 return this.binding.threadIdenticon;
2315 }
2316
2317 @Override
2318 protected ListView linkDescriptions() {
2319 return this.binding.messageContent.linkDescriptions;
2320 }
2321
2322 @Override
2323 protected LinearLayout inReplyToBox() {
2324 return this.binding.messageContent.inReplyToBox;
2325 }
2326
2327 @Override
2328 protected TextView inReplyTo() {
2329 return this.binding.messageContent.inReplyTo;
2330 }
2331
2332 @Override
2333 protected TextView inReplyToQuote() {
2334 return this.binding.messageContent.inReplyToQuote;
2335 }
2336
2337 @Override
2338 protected TextView subject() {
2339 return this.binding.messageSubject;
2340 }
2341 }
2342
2343 private static class DateSeperatorMessageItemViewHolder extends MessageItemViewHolder {
2344
2345 private final ItemMessageDateBubbleBinding binding;
2346
2347 private DateSeperatorMessageItemViewHolder(@NonNull ItemMessageDateBubbleBinding binding) {
2348 super(binding.getRoot());
2349 this.binding = binding;
2350 }
2351 }
2352
2353 private static class RtpSessionMessageItemViewHolder extends MessageItemViewHolder {
2354
2355 private final ItemMessageRtpSessionBinding binding;
2356
2357 private RtpSessionMessageItemViewHolder(@NonNull ItemMessageRtpSessionBinding binding) {
2358 super(binding.getRoot());
2359 this.binding = binding;
2360 }
2361 }
2362
2363 private static class StatusMessageItemViewHolder extends MessageItemViewHolder {
2364
2365 private final ItemMessageStatusBinding binding;
2366
2367 private StatusMessageItemViewHolder(@NonNull ItemMessageStatusBinding binding) {
2368 super(binding.getRoot());
2369 this.binding = binding;
2370 }
2371 }
2372
2373 class Thumbnailer implements GetThumbnailForCid {
2374 final Account account;
2375 final boolean canFetch;
2376 final Jid counterpart;
2377
2378 public Thumbnailer(final Message message) {
2379 account = message.getConversation().getAccount();
2380 canFetch = message.trusted() || message.getConversation().canInferPresence();
2381 counterpart = message.getCounterpart();
2382 }
2383
2384 public Thumbnailer(final Account account, final Reaction reaction, final boolean allowFetch) {
2385 canFetch = allowFetch;
2386 counterpart = reaction.from;
2387 this.account = account;
2388 }
2389
2390 @Override
2391 public Drawable getThumbnail(Cid cid) {
2392 try {
2393 DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
2394 if (f == null || !f.canRead()) {
2395 if (!canFetch) return null;
2396
2397 try {
2398 new BobTransfer(BobTransfer.uri(cid), account, counterpart, activity.xmppConnectionService).start();
2399 } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
2400 return null;
2401 }
2402
2403 Drawable d = activity.xmppConnectionService.getFileBackend().getThumbnail(f, activity.getResources(), (int) (metrics.density * 288), true);
2404 if (d == null) {
2405 new ThumbnailTask().execute(f);
2406 }
2407 return d;
2408 } catch (final IOException e) {
2409 return null;
2410 }
2411 }
2412 }
2413
2414 class ThumbnailTask extends AsyncTask<DownloadableFile, Void, Drawable[]> {
2415 @Override
2416 protected Drawable[] doInBackground(DownloadableFile... params) {
2417 if (isCancelled()) return null;
2418
2419 Drawable[] d = new Drawable[params.length];
2420 for (int i = 0; i < params.length; i++) {
2421 try {
2422 d[i] = activity.xmppConnectionService.getFileBackend().getThumbnail(params[i], activity.getResources(), (int) (metrics.density * 288), false);
2423 } catch (final IOException e) {
2424 d[i] = null;
2425 }
2426 }
2427
2428 return d;
2429 }
2430
2431 @Override
2432 protected void onPostExecute(final Drawable[] d) {
2433 if (isCancelled()) return;
2434 activity.xmppConnectionService.updateConversationUi();
2435 }
2436 }
2437}