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